Compare commits

...

No commits in common. "539c395beb43cdc8f77e85a2ce0ad71175a0a83a" and "6ea5a386cf31e9b35b9a85de80a1567e75eba312" have entirely different histories.

136 changed files with 10840 additions and 945 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

247
API.md Normal file
View File

@ -0,0 +1,247 @@
# OnlyPrompt API
This file documents the backend endpoints used by the frontend. The backend is a helper service for server communication, authentication and shared data persistence.
Base URL in local Docker setup:
```text
http://localhost:1801
```
Authentication uses a `jwt` cookie set by the login endpoint. Protected endpoints require a logged-in user.
## Auth
### Register
```http
POST /api/v1/auth/register
Content-Type: application/json
```
Request:
```json
{
"displayName": "Isabelle",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"password": "1234"
}
```
Response: created user data or validation errors.
### Login
```http
POST /api/v1/auth/login
Content-Type: application/json
```
Request:
```json
{
"userNameOrEmail": "isabelle@test.com",
"password": "1234"
}
```
Response: sets the auth cookie.
### Current User
```http
GET /api/v1/auth/me
```
Response:
```json
{
"id": "uuid",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"roles": ["User"]
}
```
### Logout
```http
POST /api/v1/auth/logout
```
Deletes the auth cookie and redirects to login.
## Profiles
### Current Profile
```http
GET /api/v1/profiles/self
```
Response:
```json
{
"displayName": "Isabelle",
"slug": "its_isabelle",
"bio": "AI creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"averageRating": 0,
"subscribers": 0
}
```
### Creator List
```http
GET /api/v1/profiles?sort=popular&limit=50&search=belle
```
Query parameters:
- `sort`: `popular`, `prompts`, `new`, `rating`
- `limit`: number of creators
- `search`: optional search term
Response: list of creator cards including follow state and avatar URL.
### Update Profile
```http
PUT /api/v1/profiles
Content-Type: application/json
```
Request:
```json
{
"displayName": "Belle",
"userName": "lady_belle",
"slug": "lady_belle",
"bio": "Prompt creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"isPublic": true
}
```
Updates profile data used on My Profile, Community and the topbar.
## Prompts
### List Prompts
```http
GET /api/v1/prompts?sortBy=date&ascending=false&limit=50&search=cat
```
Used by Marketplace. Supports sorting, search and category filtering.
### Feed
```http
GET /api/v1/feed?sortBy=date&ascending=false&limit=20
```
Used by Dashboard. Returns prompt cards with title, description, creator info, avatar and example image.
### Create Prompt
```http
POST /api/v1/prompts
Content-Type: application/json
```
Request:
```json
{
"title": "Luxury Cat Portrait",
"description": "Create a premium cat portrait prompt.",
"content": "Write the full prompt instructions here.",
"category": "art",
"subscriptionTier": null,
"slug": null,
"exampleOutput": "Example output text",
"exampleImageUrl": "data:image/png;base64,...",
"price": null
}
```
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
### Prompt Detail
```http
GET /api/v1/prompts/{id}
```
Response includes:
- title and description
- prompt content if accessible
- category
- creator information
- price or free state
- example output
- example image
- rating data
### Reviews
```http
GET /api/v1/prompts/{id}/reviews
PUT /api/v1/prompts/{id}/reviews
```
`PUT` request:
```json
{
"comment": "Helpful prompt",
"rating": 5
}
```
Used for user feedback on prompts.
## Categories
### Minimal Categories
```http
GET /api/v1/categories/minimal
```
Used by Create New and Marketplace filters.
Response:
```json
[
{
"name": "Art",
"slug": "art"
}
]
```
Default categories are created automatically when the backend starts.
## Subscriptions
### Follow or Subscribe to Creator
```http
PUT /api/v1/subscriptions/{creatorId}
DELETE /api/v1/subscriptions/{creatorId}
```
Used by Community to follow or unfollow creators.

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,5 @@
namespace OnlyPrompt.Backend.ApiModels.Category
{
public record ApiCategory(Guid Id, string Name, string Slug, string? Description);
public record ApiMinimalCategory(string Name, string Slug);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Category
{
public record ApiCreateCategoryRequest(string Name, string? Slug, string? Description);
public record ApiUpdateCategoryRequest(string? Name, string? Slug, string? Description);
}

View File

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

View File

@ -0,0 +1,8 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
public record ApiLikeState(int LikeCount, bool IsLiked);
public record ApiSaveState(int SaveCount, bool IsSaved);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Subscription
{
public record ApiSubscriptionTier(Guid Id, string Name, int Level, decimal MonthlyPrice, string? Description);
public record ApiSubscription(Guid SubscribedToId, string SubscribedToName, ApiSubscriptionTier? CurrentTier);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Subscription
{
public record ApiCreateSubscriptionTierRequest(string Name, decimal MonthlyPrice, int Level, string? Description);
public record ApiUpdateSubscriptionTierRequest(string? Name, decimal? MonthlyPrice, int? Level, string? Description);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers);
public record ApiCreatorCard(Guid UserId, string DisplayName, string Slug, string? Bio, string AvatarUrl, double AverageRating, int Subscribers, int PromptCount, bool IsFollowing);
}

View File

@ -0,0 +1,8 @@
using OnlyPrompt.Backend.ApiModels.Validators;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? UserName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating);
}

View File

@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.ApiModels.Validators
{
public class NoWhitespaceAttribute : ValidationAttribute
{
public override bool IsValid(object? value)
{
if (value is string strValue)
{
if (strValue.Any(c => char.IsWhiteSpace(c)))
return false;
return true;
}
return true; // If it's not a string, we consider it valid. Use [NoWhitespace] only on string properties.
}
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 ValidationResult.Success; // If it's not a string, we consider it valid. Use [NoWhitespace] only on string properties.
}
}
}

View File

@ -0,0 +1,83 @@
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
{
[ApiController]
[Route("api/v1/admin")]
[Authorize(Roles = ModelConstants.AdminRole)]
public class AdminController : BaseController
{
public AdminController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
private Task<UserModel?> GetNonAdminUserAsync(Guid id, bool isSysAdmin = false)
{
return _db.Users.FirstOrDefaultAsync(
u => u.Id == id
&& (isSysAdmin || u.Roles.Contains(ModelConstants.AdminRole) == false)
&& u.Roles.Contains(ModelConstants.SysAdminRole) == false
);
}
[HttpPost("users/{userId}/disable")]
public async Task<Results<Ok, NotFound<string>>> DisableUserAsync(Guid userId)
{
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.IsLockoutEnabled = true;
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpPost("users/{userId}/enable")]
public async Task<Results<Ok, NotFound<string>>> EnableUserAsync(Guid userId)
{
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.IsLockoutEnabled = false;
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpPut("users/{userId}/roles/{role}")]
public async Task<Results<Ok, NotFound<string>>> UpdateUserRolesAsync(Guid userId, string role)
{
if (ModelConstants.AllRoles.Contains(role) == false)
return TypedResults.NotFound($"No such role '{role}'");
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.Roles.Add(role);
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpDelete("users/{userId}/roles/{role}")]
public async Task<Results<Ok, NotFound<string>>> RemoveUserRoleAsync(Guid userId, string role)
{
if (ModelConstants.AllRoles.Contains(role) == false)
return TypedResults.NotFound($"No such role '{role}'");
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.Roles.Remove(role);
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
}
}

View File

@ -0,0 +1,121 @@
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 = false, HttpOnly = true, IsEssential = true, SameSite = SameSiteMode.Lax };
private readonly IPasswordHasher<UserModel> _passwordHasher;
private readonly ITokenService _jwtService;
private readonly ILogger<AuthController> _logger;
public AuthController(OnlyPromptContext db, IPasswordHasher<UserModel> passwordHasher, IMapper mapper, ILogger<AuthController> logger, ITokenService jwtService) : base(db, mapper)
{
_passwordHasher=passwordHasher;
_logger=logger;
_jwtService=jwtService;
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<Results<Ok, RedirectHttpResult, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request, [FromQuery]string redirect = null)
{
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));
if (string.IsNullOrEmpty(redirect) == false)
return TypedResults.Redirect(redirect, false);
return TypedResults.Ok();
}
[AllowAnonymous]
[HttpPost("register")]
public async Task<Results<RedirectHttpResult, ValidationProblem, Ok<ApiUser>>> RegisterAsync([FromBody] ApiRegisterRequest request, [FromQuery] string redirect = null)
{
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.CreateVersion7();
var slug = await SlugHelper.GenerateUniqueSlugAsync(request.UserName, s => _db.UserProfiles.AnyAsync(up => up.Slug == s), 32);
var avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}";
var newUser = new UserModel
{
Id = id,
Profile = new UserProfileModel
{
AvatarUrl = avatarUrl,
DisplayName = request.DisplayName,
Slug = slug,
IsPublic = true,
},
Roles = [ModelConstants.UserRole],
PasswordHash = null,
UserName = request.UserName ?? request.Email,
Email = request.Email,
IsLockoutEnabled = false,
};
newUser.PasswordHash = _passwordHasher.HashPassword(newUser, request.Password);
_db.Users.Add(newUser);
await _db.SaveChangesAsync();
if(string.IsNullOrEmpty(redirect) == false)
return TypedResults.Redirect(redirect, false);
return TypedResults.Ok(_mapper.Map<ApiUser>(newUser));
}
[HttpPost("logout")]
public RedirectHttpResult Logout()
{
this.Response.Cookies.Delete("jwt", AuthCookieOptions);
return TypedResults.Redirect("/login");
}
[Authorize]
[HttpGet("me")]
public async Task<Results<Ok<ApiUser>, NotFound<string>>> GetCurrentUserAsync()
{
var user = await GetUserAsync();
if (user is null)
return TypedResults.NotFound("User not found");
return TypedResults.Ok(_mapper.Map<ApiUser>(user));
}
}
}

View File

@ -0,0 +1,45 @@
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<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()
{
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,126 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Category;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/categories")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class CategoryController : BaseController
{
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(CategoryModel.Slug), new[] { "Slug already exists." } }
});
public CategoryController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet("minimal")]
[Authorize(Roles = ModelConstants.UserRole)]
public async Task<ApiMinimalCategory[]> GetMinimalCategoriesAsync()
{
var categories = await _db.Categories
.ProjectTo<ApiMinimalCategory>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return categories;
}
[HttpGet]
[Authorize(Roles = ModelConstants.UserRole)]
public async Task<ApiCategory[]> GetCategoriesAsync([Range(0, double.MaxValue)][FromQuery] int offset = 0, [Range(1, 100)][FromQuery] int limit = 20)
{
var categories = await _db.Categories
.OrderBy(c => c.Id)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiCategory>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return categories;
}
[HttpPost]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request)
{
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug);
if (exists)
return SlugExistsProblem;
var model = _mapper.Map<CategoryModel>(request);
if (string.IsNullOrWhiteSpace(model.Slug))
model.Slug = await SlugHelper.GenerateUniqueSlugAsync(request.Name, slug => _db.Categories.AnyAsync(c => c.Slug == slug), ModelConstants.MaxSlugLength);
_db.Categories.Add(model);
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiCategory>(model));
}
[HttpPut("{id}")]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<Ok<ApiCategory>, NotFound<string>, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request)
{
var category = await _db.Categories.FindByIdentifierAsync(id);
if (category is null)
return TypedResults.NotFound("Category not found");
if (string.IsNullOrWhiteSpace(request.Name) == false)
category.Name = request.Name;
if(string.IsNullOrWhiteSpace(request.Description) == false)
category.Description = request.Description;
if (string.IsNullOrWhiteSpace(request.Slug) == false && request.Slug != category.Slug)
{
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug && c.Id != category.Id);
if (exists)
return SlugExistsProblem;
category.Slug = request.Slug;
}
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiCategory>(category));
}
[HttpDelete("{id}")]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null)
{
var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id);
if (hasPrompts)
{
if (replaceWith.HasValue == false)
return TypedResults.BadRequest("Category has associated prompts. Provide a replacement category to reassign them to.");
var replacement = await _db.Categories.FindByIdentifierAsync(replaceWith.Value);
if(replacement is null)
return TypedResults.NotFound("Replacement category not found.");
await _db.Prompts.Where(p => p.CategoryId == id.Id)
.ExecuteUpdateAsync(p => p.SetProperty(p => p.CategoryId, replacement.Id));
}
var count = await _db.Categories.OfIdentifer(id)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound("Category not found");
return TypedResults.NoContent();
}
}
}

View File

@ -0,0 +1,79 @@
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(
[Range(0, double.MaxValue)][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.AsQueryable();
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 => ascending
? query.OrderBy(x => x.Likes.Count).ThenBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0)
: query.OrderByDescending(x => x.Likes.Count).ThenByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0),
_ => query
};
var prompts = await query
.Skip(offset)
.Take(limit)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
)).ToArrayAsync();
return prompts;
}
}
}

View File

@ -0,0 +1,168 @@
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.Database.Models;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/profiles")]
[Authorize(Roles = ModelConstants.UserRole)]
public class ProfileController : BaseController
{
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(UserProfileModel.Slug), new[] { "Slug already exists." } }
});
private static ValidationProblem UserNameExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(UserModel.UserName), new[] { "Username is already taken." } }
});
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet("self")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetSelfProfileAsync()
{
var userId = User.GetUserId();
if (userId is null)
return TypedResults.NotFound("Profile not found.");
var profile = await _db.UserProfiles
.Where(up => up.Id == userId.Value)
.Select(up => new ApiUserProfile(
up.DisplayName,
up.Slug,
up.Bio,
up.AvatarUrl,
up.Specialities,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id)
))
.FirstOrDefaultAsync();
if (profile is null)
return TypedResults.NotFound("Profile not found.");
return TypedResults.Ok(profile);
}
[HttpGet("{id}")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetProfileAsync(Identifier id)
{
var userId = User.GetUserId();
var profile = await _db.UserProfiles.OfIdentifer(id)
.ProjectTo<ApiUserProfile>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
if (profile is null)
return TypedResults.NotFound("Profile not found or is private.");
return TypedResults.Ok(profile);
}
[HttpGet]
public async Task<ApiCreatorCard[]> GetCreatorsAsync(
[Range(0, int.MaxValue)] int offset = 0,
[Range(1, 100)] int limit = 20,
[FromQuery] string sort = "popular",
[FromQuery] string? search = null
)
{
var userId = User.GetUserId();
var query = _db.UserProfiles.Where(up => up.Id != userId);
if (string.IsNullOrWhiteSpace(search) == false)
query = query.Where(up =>
up.DisplayName.Contains(search) ||
up.Slug.Contains(search) ||
up.User.UserName.Contains(search) ||
(up.Bio != null && up.Bio.Contains(search)));
var projected = query.Select(up => new ApiCreatorCard(
up.Id,
up.DisplayName,
up.Slug,
up.Bio,
up.AvatarUrl,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id),
_db.Prompts.Count(p => p.CreatorId == up.Id),
_db.Subscriptions.Any(s => s.SubscribedToId == up.Id && s.SubscriberId == userId)
));
var allCreators = await projected.ToArrayAsync();
return (sort switch
{
"rating" => allCreators.OrderByDescending(c => c.AverageRating),
"new" => allCreators.OrderByDescending(c => c.UserId),
"prompts" => allCreators.OrderByDescending(c => c.PromptCount),
_ => allCreators.OrderByDescending(c => c.Subscribers),
}).Skip(offset).Take(limit).ToArray();
}
[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.");
var user = await GetUserAsync();
if (user is null)
return TypedResults.NotFound("User not found.");
if (string.IsNullOrEmpty(request.UserName) == false)
{
if (await _db.Users.AnyAsync(u => u.UserName == request.UserName && u.Id != user.Id))
return UserNameExistsProblem;
user.UserName = request.UserName;
}
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(request.AvatarUrl is not null)
self.AvatarUrl = request.AvatarUrl;
if(request.Bio is not null)
self.Bio = request.Bio;
if(request.Specialities is not null)
self.Specialities = request.Specialities;
if (string.IsNullOrEmpty(request.DisplayName) == false)
self.DisplayName = request.DisplayName;
self.IsPublic = request.IsPublic;
await _db.SaveChangesAsync();
var result = new ApiUserProfile(
self.DisplayName,
self.Slug,
self.Bio,
self.AvatarUrl,
self.Specialities,
await _db.Reviews.Where(r => r.Prompt.CreatorId == self.Id).AverageAsync(r => (double?)r.Rating) ?? 0.0,
await _db.Subscriptions.CountAsync(s => s.SubscribedToId == self.Id)
);
return TypedResults.Ok(result);
}
}
}

View File

@ -0,0 +1,401 @@
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]
public async Task<ApiMinimalPrompt[]> GetPromptsAsync(
[Range(0, int.MaxValue)][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] string? search = null
)
{
var userId = User.GetUserId();
var query = _db.Prompts.AsQueryable();
if (category.HasValue)
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => x.Title.Contains(search) || x.Description.Contains(search));
query = sortBy switch
{
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Likes.Count).ThenBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0)
: query.OrderByDescending(x => x.Likes.Count).ThenByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0),
_ => query
};
var prompts = await query
.Skip(offset)
.Take(limit)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
x.CreatorId == userId || ((x.Price == null || x.Price <= 0) && (x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)))
)).ToArrayAsync();
return prompts;
}
[HttpGet("mine")]
public async Task<ApiMinimalPrompt[]> GetOwnPromptsAsync()
{
var userId = User.GetUserId();
var prompts = await _db.Prompts
.Where(x => x.CreatorId == userId)
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
true
)).ToArrayAsync();
return prompts;
}
[HttpGet("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
if (prompt.CreatorId != userId && prompt.Price.HasValue && prompt.Price.Value > 0)
return TypedResults.NotFound("Prompt not found or requires payment");
var canAccess = await GetAccessiblePrompts(userId.Value).AnyAsync(p => p.Id == prompt.Id);
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with
{
Content = canAccess ? prompt.Prompt : null,
CanAccess = canAccess,
IsLiked = prompt.Likes.Any(l => l.UserId == userId),
LikeCount = prompt.Likes.Count,
IsSaved = prompt.Saves.Any(s => s.UserId == userId),
SaveCount = prompt.Saves.Count
};
return TypedResults.Ok(apiPrompt);
}
[HttpPut("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> UpdatePromptAsync(Identifier id, [FromBody] ApiUpdatePromptRequest request)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync(p => p.CreatorId == userId);
if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission");
var category = await _db.Categories.FindByIdentifierAsync(new Identifier(request.Category));
if (category is null)
return TypedResults.NotFound("Category not found");
prompt.Title = request.Title;
prompt.Description = request.Description;
prompt.Prompt = request.Content;
prompt.Category = category;
prompt.ExampleOutput = request.ExampleOutput;
prompt.ExampleImageUrl = request.ExampleImageUrl;
prompt.Price = request.Price;
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
return TypedResults.Ok(apiPrompt);
}
[HttpPut("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> LikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptLikes.AnyAsync(l => l.PromptId == prompt.Id && l.UserId == userId);
if (exists == false)
{
_db.PromptLikes.Add(new PromptLikeModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, true));
}
[HttpDelete("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> UnlikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptLikes
.Where(l => l.PromptId == prompt.Id && l.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, false));
}
[HttpPut("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> SavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptSaves.AnyAsync(s => s.PromptId == prompt.Id && s.UserId == userId);
if (exists == false)
{
_db.PromptSaves.Add(new PromptSaveModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, true));
}
[HttpDelete("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> UnsavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptSaves
.Where(s => s.PromptId == prompt.Id && s.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, false));
}
[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(new Identifier(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,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = request.Price,
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>, BadRequest<string>, 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");
if(prompt.CreatorId == userId)
return TypedResults.BadRequest("Cannot review your own prompt");
var review = await _db.Reviews.FirstOrDefaultAsync(
r => r.PromptId == prompt.Id
&& r.ReviewerId == userId
);
if (review is null)
{
review = new ReviewModel
{
PromptId = prompt.Id,
ReviewerId = userId.Value,
Comment = request.Comment,
Rating = request.Rating
};
_db.Reviews.Add(review);
}
else
{
review.Comment = request.Comment;
review.Rating = request.Rating;
}
await _db.SaveChangesAsync();
var apiReview = _mapper.Map<ApiReview>(review);
return TypedResults.Ok(apiReview);
}
[HttpDelete("{promptId}/reviews/{reviewerId}")]
public async Task<Results<NoContent, NotFound<string>>> DeleteReviewAsync(Identifier promptId, Guid reviewerId)
{
var userId = User.GetUserId();
var isAdmin = User.IsInRole(ModelConstants.AdminRole);
var count = await _db.Reviews
.Where(
r => (promptId.Id.HasValue ? r.PromptId == promptId.Id : r.Prompt.Slug == promptId.Slug)
&& (r.ReviewerId == reviewerId || isAdmin)
)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound("Review not found or no permission");
return TypedResults.NoContent();
}
}
}

View File

@ -0,0 +1,180 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Subscription;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
using System.Formats.Asn1;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/subscriptions")]
[Authorize(Roles = ModelConstants.UserRole)]
public class SubscriptionController : BaseController
{
private static ValidationProblem TierLevelExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(SubscriptionTierModel.Level), new[] { "Tier with this level already exists." } }
});
public SubscriptionController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpPut("{userId}/{level?}")]
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId, int? level = null)
{
var userId = User.GetUserId();
var subscribeTo = await _db.Users.Include(x => x.SubscriptionTiers.Where(st => st.Level == level))
.FirstOrDefaultAsync(
user => subscribeToId.Id.HasValue ? user.Id == subscribeToId.Id.Value : user.Profile.Slug == subscribeToId.Slug
);
if (subscribeTo is null)
return TypedResults.NotFound($"No user found with identifier {subscribeToId}");
if (subscribeTo.Id == userId)
return TypedResults.BadRequest("Cannot subscribe to yourself");
SubscriptionTierModel? tier = subscribeTo.SubscriptionTiers.FirstOrDefault();
if (level.HasValue && tier is null)
return TypedResults.NotFound($"No subscription tier found for user {subscribeToId} with level {level.Value}");
var existingSubscription = await _db.Subscriptions.FirstOrDefaultAsync(
sub => (subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug)
&& sub.SubscriberId == userId
);
if (existingSubscription is null)
{
existingSubscription = new SubscriptionModel
{
SubscribedTo = subscribeTo,
SubscriberId = userId.Value,
SubscriptionTier = tier
};
_db.Subscriptions.Add(existingSubscription);
}
else
{
existingSubscription.SubscriptionTier = tier;
}
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpGet]
public async Task<ApiSubscription[]> GetSubscriptionsAsync([Range(0, double.MaxValue)]int offset = 0, [Range(1, 100)]int limit = 20)
{
var userId = User.GetUserId();
var subscriptions = await _db.Subscriptions
.Where(x => x.SubscriberId == userId)
.OrderBy(x => x.SubscribedToId)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiSubscription>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return subscriptions;
}
[HttpGet("{userId}")]
public async Task<ApiSubscription?> GetCurrentSubscriptionAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{
var userId = User.GetUserId();
var subscription = await _db.Subscriptions
.Where(
sub => (subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug)
&& sub.SubscriberId == userId
)
.ProjectTo<ApiSubscription>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
return subscription;
}
[HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{
var userId = User.GetUserId();
var count = await _db.Subscriptions
.Where(
sub => (subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug)
&& sub.SubscriberId == userId
)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound($"No subscription found for user {subscribeToId}");
return TypedResults.Ok();
}
[HttpPost("tiers")]
public async Task<Results<Ok<ApiSubscriptionTier>, ValidationProblem>> CreateOrUpdateSubscriptionTierAsync([FromBody] ApiCreateSubscriptionTierRequest tier)
{
var userId = User.GetUserId();
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
if (levelExists)
return TierLevelExistsProblem;
var model = _mapper.Map<SubscriptionTierModel>(tier);
model.UserId = userId!.Value;
_db.SubscriptionTiers.Add(model);
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(model));
}
[HttpPut("tiers/{id}")]
public async Task<Results<Ok<ApiSubscriptionTier>, NotFound<string>, ValidationProblem>> UpdateSubscriptionTierAsync(Guid id, [FromBody] ApiUpdateSubscriptionTierRequest tier)
{
var userId = User.GetUserId();
var existingTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId);
if (existingTier is null)
return TypedResults.NotFound($"No subscription tier found with id {id}");
if (existingTier.Level != tier.Level)
{
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
if (levelExists)
return TierLevelExistsProblem;
}
if (string.IsNullOrEmpty(tier.Name) == false)
existingTier.Name = tier.Name;
if (string.IsNullOrEmpty(tier.Description) == false)
existingTier.Description = tier.Description;
if (tier.Level.HasValue)
existingTier.Level = tier.Level.Value;
if (tier.MonthlyPrice.HasValue)
existingTier.MonthlyPrice = tier.MonthlyPrice.Value;
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(existingTier));
}
[HttpDelete("tiers/{id}")]
public async Task<Results<Ok, NotFound<string>>> DeleteSubscriptionTierAsync(Guid id)
{
var userId = User.GetUserId();
var count = await _db.SubscriptionTiers
.Where(t => t.Id == id && t.UserId == userId)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound($"No subscription tier found with id {id}");
return TypedResults.Ok();
}
}
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Core
{
public class EntityBase : IEntity, ITrackableEntity
{
[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.Core
{
public interface ITrackableEntity
{
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}

View File

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

View File

@ -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,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
public class PromptLikeModel
{
[ForeignKey(nameof(Prompt))]
public Guid PromptId { get; set; }
public virtual PromptModel Prompt { get; set; }
[ForeignKey(nameof(User))]
public Guid UserId { get; set; }
public virtual UserModel User { get; set; }
}
}

View File

@ -0,0 +1,52 @@
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 virtual UserModel Creator { get; set; }
[Required]
[ForeignKey(nameof(Category))]
public Guid CategoryId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual CategoryModel Category { get; set; }
[MaxLength(200)]
public required string Title { get; set; }
public required string Prompt { get; set; }
[MaxLength(1000)]
public required string Description { get; set; }
public string? ExampleOutput { get; set; }
public string? ExampleImageUrl { get; set; }
public decimal? Price { 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>();
public virtual IList<PromptLikeModel> Likes { get; set; } = new List<PromptLikeModel>();
public virtual IList<PromptSaveModel> Saves { get; set; } = new List<PromptSaveModel>();
}
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
public class PromptSaveModel
{
[ForeignKey(nameof(Prompt))]
public Guid PromptId { get; set; }
public virtual PromptModel Prompt { get; set; }
[ForeignKey(nameof(User))]
public Guid UserId { get; set; }
public virtual UserModel User { get; set; }
}
}

View File

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[PrimaryKey(nameof(ReviewerId), nameof(PromptId))]
public class ReviewModel : ITrackableEntity
{
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[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 virtual UserModel SubscribedTo { get; set; }
[Required]
[ForeignKey(nameof(Subscriber))]
public Guid SubscriberId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public 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 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,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(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 List<string> Roles { get; set; } = new List<string>();
[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 virtual IList<SubscriptionTierModel> SubscriptionTiers { get; set; } = new List<SubscriptionTierModel>();
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; } = true;
}
}

View File

@ -0,0 +1,123 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using OnlyPrompt.Backend.Database.Core;
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; }
public DbSet<PromptLikeModel> PromptLikes { get; set; }
public DbSet<PromptSaveModel> PromptSaves { 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)
{
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);
});
modelBuilder.Entity<PromptLikeModel>(entity =>
{
entity.HasKey(e => new { e.PromptId, e.UserId });
entity.HasOne(e => e.Prompt)
.WithMany(p => p.Likes)
.HasForeignKey(e => e.PromptId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<PromptSaveModel>(entity =>
{
entity.HasKey(e => new { e.PromptId, e.UserId });
entity.HasOne(e => e.Prompt)
.WithMany(p => p.Saves)
.HasForeignKey(e => e.PromptId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
}

View File

@ -0,0 +1,31 @@
# 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/"]
ADD ["OnlyPrompt.Frontend", "OnlyPrompt.Backend/wwwroot"]
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,419 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OnlyPrompt.Backend.Database;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
[DbContext(typeof(OnlyPromptContext))]
[Migration("20260412002927_ReviewManyToMany")]
partial class ReviewManyToMany
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Categories");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Prompt")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("SubscriptionTierId");
b.ToTable("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ReviewerId", "PromptId");
b.HasIndex("PromptId");
b.ToTable("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid");
b.Property<Guid>("SubscribedToId")
.HasColumnType("uuid");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.HasKey("SubscriberId", "SubscribedToId");
b.HasIndex("SubscribedToId");
b.HasIndex("SubscriptionTierId");
b.ToTable("UserSubscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Level", "UserId")
.IsUnique();
b.ToTable("SubscriptionTiers");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext");
b.Property<bool>("IsLockoutEnabled")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublic")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<string>("Specialities")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("UserProfiles");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
.WithMany("Prompts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
.WithMany("Prompts")
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Prompts")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("Creator");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
.WithMany("Reviews")
.HasForeignKey("PromptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prompt");
b.Navigation("Reviewer");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
.WithMany("Subscribers")
.HasForeignKey("SubscribedToId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
.WithMany("Subscriptions")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Subscriptions")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SubscribedTo");
b.Navigation("Subscriber");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithOne("Profile")
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Navigation("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Navigation("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Navigation("Prompts");
b.Navigation("Subscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Navigation("Profile")
.IsRequired();
b.Navigation("Prompts");
b.Navigation("Subscribers");
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class ReviewManyToMany : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_Reviews",
table: "Reviews");
migrationBuilder.DropIndex(
name: "IX_Reviews_ReviewerId_PromptId",
table: "Reviews");
migrationBuilder.DropColumn(
name: "Id",
table: "Reviews");
migrationBuilder.DropColumn(
name: "Content",
table: "Prompts");
migrationBuilder.AddColumn<string>(
name: "Description",
table: "Prompts",
type: "character varying(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Prompt",
table: "Prompts",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
defaultValue: "");
migrationBuilder.AddPrimaryKey(
name: "PK_Reviews",
table: "Reviews",
columns: new[] { "ReviewerId", "PromptId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_Reviews",
table: "Reviews");
migrationBuilder.DropColumn(
name: "Description",
table: "Prompts");
migrationBuilder.DropColumn(
name: "Prompt",
table: "Prompts");
migrationBuilder.AddColumn<Guid>(
name: "Id",
table: "Reviews",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddColumn<string>(
name: "Content",
table: "Prompts",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddPrimaryKey(
name: "PK_Reviews",
table: "Reviews",
column: "Id");
migrationBuilder.CreateIndex(
name: "IX_Reviews_ReviewerId_PromptId",
table: "Reviews",
columns: new[] { "ReviewerId", "PromptId" },
unique: true);
}
}
}

View File

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptCreateDetails : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExampleImageUrl",
table: "Prompts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExampleOutput",
table: "Prompts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Price",
table: "Prompts",
type: "numeric",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExampleImageUrl",
table: "Prompts");
migrationBuilder.DropColumn(
name: "ExampleOutput",
table: "Prompts");
migrationBuilder.DropColumn(
name: "Price",
table: "Prompts");
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptLikes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PromptLikes",
columns: table => new
{
PromptId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PromptLikes", x => new { x.PromptId, x.UserId });
table.ForeignKey(
name: "FK_PromptLikes_Prompts_PromptId",
column: x => x.PromptId,
principalTable: "Prompts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PromptLikes_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PromptLikes_UserId",
table: "PromptLikes",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PromptLikes");
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptSaves : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PromptSaves",
columns: table => new
{
PromptId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PromptSaves", x => new { x.PromptId, x.UserId });
table.ForeignKey(
name: "FK_PromptSaves_Prompts_PromptId",
column: x => x.PromptId,
principalTable: "Prompts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PromptSaves_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PromptSaves_UserId",
table: "PromptSaves",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PromptSaves");
}
}
}

View File

@ -0,0 +1,424 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ExampleImageUrl")
.HasColumnType("text");
b.Property<string>("ExampleOutput")
.HasColumnType("text");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("SubscriptionTierId");
b.ToTable("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ReviewerId", "PromptId");
b.HasIndex("PromptId");
b.ToTable("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid");
b.Property<Guid>("SubscribedToId")
.HasColumnType("uuid");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.HasKey("SubscriberId", "SubscribedToId");
b.HasIndex("SubscribedToId");
b.HasIndex("SubscriptionTierId");
b.ToTable("UserSubscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Level", "UserId")
.IsUnique();
b.ToTable("SubscriptionTiers");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext");
b.Property<bool>("IsLockoutEnabled")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublic")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<string>("Specialities")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("UserProfiles");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
.WithMany("Prompts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
.WithMany("Prompts")
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Prompts")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("Creator");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
.WithMany("Reviews")
.HasForeignKey("PromptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prompt");
b.Navigation("Reviewer");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
.WithMany("Subscribers")
.HasForeignKey("SubscribedToId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
.WithMany("Subscriptions")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Subscriptions")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SubscribedTo");
b.Navigation("Subscriber");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithOne("Profile")
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Navigation("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Navigation("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Navigation("Prompts");
b.Navigation("Subscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Navigation("Profile")
.IsRequired();
b.Navigation("Prompts");
b.Navigation("Subscribers");
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<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="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\" />
<Folder Include="wwwroot\" />
</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,211 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Tokens;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils;
using Scalar.AspNetCore;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme);
builder.Services.AddDbContext<OnlyPromptContext>(opts =>
{
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
opts.UseLazyLoadingProxies();
opts.ConfigureWarnings(warnings =>
warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
});
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
builder.Services.AddValidation(opts =>
{
opts.MaxDepth = 10;
});
builder.Services.AddAuthorization();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts =>
{
opts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = config["Jwt:Issuer"],
ValidAudience = config["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]))
};
opts.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Cookies.ContainsKey("jwt"))
context.Token = context.Request.Cookies["jwt"];
return Task.CompletedTask;
}
};
});
builder.Services.AddControllers().AddJsonOptions(jsonOpts =>
{
jsonOpts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddOpenApi(opts => opts.AddScalarTransformers());
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
var rewrite = new RewriteOptions()
.AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true);
app.UseRewriter(rewrite);
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
var dir = Path.GetFullPath("./../OnlyPrompt.Frontend");
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(dir),
RedirectToAppendTrailingSlash = true,
HttpsCompression = HttpsCompressionMode.Compress,
});
}
else
{
app.UseStaticFiles();
}
app.MapControllers();
app.MapGet("/", async (HttpContext context) =>
{
var authResult = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
return authResult.Succeeded
? Results.Redirect("/dashboard")
: Results.Redirect("/login");
});
app.MapFallbackToFile("/login.html");
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync();
await EnsurePromptDetailColumnsAsync(db);
await EnsurePromptLikesTableAsync(db);
await EnsurePromptSavesTableAsync(db);
await SeedDefaultCategoriesAsync(db);
app.Run();
static async Task EnsurePromptDetailColumnsAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleImageUrl" text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleOutput" text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "Price" numeric;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ALTER COLUMN "Prompt" TYPE text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ALTER COLUMN "ExampleOutput" TYPE text;
""");
}
static async Task EnsurePromptLikesTableAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PromptLikes" (
"PromptId" uuid NOT NULL,
"UserId" uuid NOT NULL,
CONSTRAINT "PK_PromptLikes" PRIMARY KEY ("PromptId", "UserId"),
CONSTRAINT "FK_PromptLikes_Prompts_PromptId" FOREIGN KEY ("PromptId") REFERENCES "Prompts" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_PromptLikes_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS "IX_PromptLikes_UserId" ON "PromptLikes" ("UserId");
""");
}
static async Task EnsurePromptSavesTableAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PromptSaves" (
"PromptId" uuid NOT NULL,
"UserId" uuid NOT NULL,
CONSTRAINT "PK_PromptSaves" PRIMARY KEY ("PromptId", "UserId"),
CONSTRAINT "FK_PromptSaves_Prompts_PromptId" FOREIGN KEY ("PromptId") REFERENCES "Prompts" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_PromptSaves_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS "IX_PromptSaves_UserId" ON "PromptSaves" ("UserId");
""");
}
static async Task SeedDefaultCategoriesAsync(OnlyPromptContext db)
{
var defaults = new[]
{
("creative-writing", "Creative Writing"),
("coding", "Coding"),
("art", "Art"),
("marketing", "Marketing"),
("video", "Video"),
("data", "Data")
};
foreach (var (slug, name) in defaults)
{
if (await db.Categories.AnyAsync(c => c.Slug == slug))
continue;
db.Categories.Add(new CategoryModel
{
Id = Guid.CreateVersion7(),
Slug = slug,
Name = name,
Description = $"{name} prompts"
});
}
await db.SaveChangesAsync();
}

View File

@ -0,0 +1,25 @@
{
"profiles": {
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "https://localhost:7163/scalar",
"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,111 @@
using AutoMapper;
using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.ApiModels.Category;
using OnlyPrompt.Backend.ApiModels.Prompt;
using OnlyPrompt.Backend.ApiModels.Subscription;
using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database.Models;
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());
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.CategoryName, x => x.Category.Name)
.MapCtorParamFrom(x => x.CategorySlug, x => x.Category.Slug)
.MapCtorParamFrom(x => x.ExampleOutput, x => x.ExampleOutput)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.MapCtorParamFrom(x => x.IsSaved, x => false)
.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))
.MapCtorParamFrom(x => x.CanAccess, x => false);
config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Title, x => x.Title)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorAvatarUrl, x => x.Creator.Profile.AvatarUrl)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.CanAccess, x => true);
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);
config.CreateMap<CategoryModel, ApiCategory>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
config.CreateMap<CategoryModel, ApiMinimalCategory>()
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
config.CreateMap<ApiCreateCategoryRequest, CategoryModel>()
.MapMemberFrom(x => x.Description, x => x.Description)
.MapMemberFrom(x => x.Name, x => x.Name)
.MapMemberFrom(x => x.Slug, x => x.Slug);
config.CreateMap<SubscriptionTierModel, ApiSubscriptionTier>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Level, x => x.Level)
.MapCtorParamFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
.MapCtorParamFrom(x => x.Description, x => x.Description);
config.CreateMap<ApiCreateSubscriptionTierRequest, SubscriptionTierModel>()
.MapMemberFrom(x => x.Name, x => x.Name)
.MapMemberFrom(x => x.Level, x => x.Level)
.MapMemberFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
.MapMemberFrom(x => x.Description, x => x.Description);
config.CreateMap<SubscriptionModel, ApiSubscription>()
.MapCtorParamFrom(x => x.SubscribedToId, x => x.SubscribedToId)
.MapCtorParamFrom(x => x.SubscribedToName, x => x.SubscribedTo.Profile.DisplayName)
.MapCtorParamFrom(x => x.CurrentTier, x => x.SubscriptionTier);
}
}
}

View File

@ -0,0 +1,48 @@
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,80 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using OnlyPrompt.Backend.Database.Models;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
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 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)
{
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,42 @@
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('-');
if (maxLength.HasValue)
slug = slug.Limit(maxLength.Value);
return slug;
}
private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static async Task<string> GenerateUniqueSlugAsync(string input, Func<string, Task<bool>> existsFunc, int? maxLenght)
{
var slug = GenerateSlug(input);
var exists = await existsFunc(slug);
if (exists)
{
var suffix = Random.Shared.GetString(8, SuffixChars);
if (maxLenght.HasValue)
slug = slug.Limit(maxLenght.Value - 9);
slug = $"{slug}-{suffix}";
}
return slug;
}
}
}

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=localhost;Port=2803;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": "*"
}

16
OnlyPrompt.Frontend/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "msedge",
"request": "launch",
"name": "Open current page in Edge",
"cwd": "${workspaceRoot}",
"url": "https://localhost:7163/${fileBasename}"
}
]
}

View File

@ -0,0 +1,4 @@
{
"editor.tabSize": 2,
"editor.indentSize": 2
}

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/chats.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -108,7 +109,7 @@
</div>
<script>
fetch('../html/sidebar.html')
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
@ -121,9 +122,9 @@
if (chatsLink) chatsLink.classList.add('active');
});
fetch('../html/topbar.html')
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
</html>

View File

@ -0,0 +1,204 @@
<!-- OnlyPrompt - Community page:
- Discover creators, follow/unfollow, dynamic via API -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Discover Creators</title>
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/base.css">
<link rel="stylesheet" href="../css/sidebar.css">
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/community.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<main class="creators-main">
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</div>
<div class="filter-buttons">
<button class="filter-btn active" data-sort="popular">Popular</button>
<button class="filter-btn" data-sort="prompts">Rising</button>
<button class="filter-btn" data-sort="new">New</button>
<button class="filter-btn" data-sort="rating">Top Rated</button>
</div>
<div class="creators-grid" id="creators-grid"></div>
<div id="creators-empty" style="display:none; text-align:center; padding:60px 20px; color:#64748b;">
<i class="bi bi-people" style="font-size:3rem; display:block; margin-bottom:16px;"></i>
<h3 id="creators-empty-title" style="margin-bottom:8px;">No creators found</h3>
<p id="creators-empty-text">Check back later for new creators to follow.</p>
</div>
<div id="creators-error" style="display:none; text-align:center; padding:60px 20px; color:#ef4444;">
<i class="bi bi-exclamation-circle" style="font-size:3rem; display:block; margin-bottom:16px;"></i>
<h3 style="margin-bottom:8px;">Could not load creators</h3>
<p id="creators-error-msg"></p>
</div>
</main>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ─────────────────────────────────────────────
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
document.querySelectorAll('#sidebar-container .sidebar a').forEach(l => l.classList.remove('active'));
const thirdLink = document.querySelectorAll('#sidebar-container .sidebar li a')[2];
if (thirdLink) thirdLink.classList.add('active');
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
// ── Helpers ──────────────────────────────────────────────────────
function renderStars(rating) {
if (!rating) return '';
const stars = Math.round(rating);
return `<span style="color:#f59e0b">${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}</span> <span style="color:#64748b;font-size:0.8rem">${rating.toFixed(1)}</span>`;
}
function renderCard(c) {
return `
<div class="creator-card">
<img class="creator-avatar"
src="${c.avatarUrl || '../images/content/cat.png'}"
alt="${c.displayName}"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">
<div class="creator-info">
<h3 class="creator-name"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">${c.displayName}</h3>
<div class="creator-handle">@${c.slug}</div>
<p class="creator-bio">${c.bio ?? 'No bio yet.'}</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> ${c.promptCount} prompts</span>
<span><i class="bi bi-people"></i> ${c.subscribers}</span>
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ''}
</div>
<button class="follow-btn ${c.isFollowing ? 'following' : ''}"
data-userid="${c.userId}"
data-following="${c.isFollowing}">
${c.isFollowing ? 'Following' : 'Follow'}
</button>
</div>
</div>`;
}
// ── Follow / Unfollow ────────────────────────────────────────────
async function toggleFollow(btn) {
const userId = btn.dataset.userid;
const isFollowing = btn.dataset.following === 'true';
btn.disabled = true;
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
if (res.status === 401) { location.href = '/login'; return; }
if (res.ok) {
const nowFollowing = !isFollowing;
btn.dataset.following = nowFollowing;
btn.textContent = nowFollowing ? 'Following' : 'Follow';
btn.classList.toggle('following', nowFollowing);
}
btn.disabled = false;
}
// ── Load Creators ────────────────────────────────────────────────
const grid = document.getElementById('creators-grid');
const emptyEl = document.getElementById('creators-empty');
const emptyTitle = document.getElementById('creators-empty-title');
const emptyText = document.getElementById('creators-empty-text');
const errorEl = document.getElementById('creators-error');
const errorMsg = document.getElementById('creators-error-msg');
let activeSort = 'popular';
let currentSearch = new URLSearchParams(location.search).get('search') || '';
function getSearchTerm() {
return currentSearch.trim();
}
async function loadCreators(sort = activeSort) {
activeSort = sort;
grid.innerHTML = '';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
try {
const params = new URLSearchParams({
sort,
limit: '50'
});
const search = getSearchTerm();
if (search) params.set('search', search);
const res = await fetch(`/api/v1/profiles?${params}`);
if (res.status === 401) { location.href = '/login'; return; }
if (!res.ok) throw new Error(`Server error ${res.status}`);
const creators = await res.json();
if (creators.length === 0) {
const search = getSearchTerm();
emptyTitle.textContent = search ? 'No matching creators' : 'No creators found';
emptyText.textContent = search
? `No creator matches "${search}". Try another name or clear the search.`
: 'Create another local user to see creators here.';
emptyEl.style.display = 'block';
return;
}
grid.innerHTML = creators.map(renderCard).join('');
grid.querySelectorAll('.follow-btn').forEach(btn => {
btn.addEventListener('click', () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = 'block';
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ───────────────────────────────────────────────
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadCreators(btn.dataset.sort);
});
});
window.applyCreatorSearch = (value) => {
currentSearch = value.trim();
loadCreators(activeSort);
};
loadCreators();
</script>
</body>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/create.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -27,8 +28,8 @@
<main class="create-main">
<div class="create-container">
<div class="create-header">
<h1>Create AI Prompt</h1>
<p>Design and save custom prompts for your AI workflows.</p>
<h1 id="create-title">Create AI Prompt</h1>
<p id="create-subtitle">Design and save custom prompts for your AI workflows.</p>
</div>
<form id="createPromptForm" class="create-form" enctype="multipart/form-data">
@ -95,9 +96,10 @@
<!-- Submit Button -->
<div class="form-actions">
<button type="submit" class="submit-btn">Publish Prompt</button>
<button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button>
<button type="button" class="cancel-btn">Cancel</button>
</div>
<p id="create-status" style="text-align:center;color:#64748b;margin:0;"></p>
</form>
</div>
</main>
@ -110,6 +112,8 @@
const paidBtn = document.getElementById('paidBtn');
const priceField = document.getElementById('priceField');
const priceInput = document.getElementById('price');
const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn');
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
@ -128,6 +132,7 @@
const imageInput = document.getElementById('exampleImage');
const imagePreview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
let exampleImageUrl = '';
if (imageInput) {
imageInput.addEventListener('change', function(event) {
@ -135,32 +140,150 @@
if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) {
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
exampleImageUrl = e.target.result;
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
imagePreview.style.display = 'none';
previewImg.src = '#';
exampleImageUrl = '';
if (file) alert('Please upload a PNG or JPG image.');
}
});
}
// Handle form submission (demo only)
document.getElementById('createPromptForm').addEventListener('submit', (e) => {
async function loadCategories() {
const categorySelect = document.getElementById('category');
try {
const response = await fetch('/api/v1/categories/minimal');
if (!response.ok) return;
const categories = await response.json();
if (!categories.length) return;
categorySelect.innerHTML = categories
.map((category) => `<option value="${category.slug}">${category.name}</option>`)
.join('');
} catch {
// Keep the static fallback categories.
}
}
async function loadPromptForEdit() {
if (!editPromptId) return;
document.getElementById('create-title').textContent = 'Edit AI Prompt';
document.getElementById('create-subtitle').textContent = 'Update your published prompt.';
submitPromptBtn.textContent = 'Save Changes';
const status = document.getElementById('create-status');
status.textContent = 'Loading prompt...';
try {
const response = await fetch(`/api/v1/prompts/${editPromptId}`);
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Prompt could not be loaded.');
const prompt = await response.json();
document.getElementById('title').value = prompt.title || '';
document.getElementById('description').value = prompt.description || '';
document.getElementById('category').value = prompt.categorySlug || document.getElementById('category').value;
document.getElementById('promptContent').value = prompt.content || '';
document.getElementById('exampleOutput').value = prompt.exampleOutput || '';
exampleImageUrl = prompt.exampleImageUrl || '';
if (exampleImageUrl) {
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
}
if (prompt.price != null && Number(prompt.price) > 0) {
paidBtn.click();
priceInput.value = Number(prompt.price);
} else {
freeBtn.click();
}
status.textContent = '';
} catch (error) {
status.textContent = error.message;
}
}
// Handle form submission
document.getElementById('createPromptForm').addEventListener('submit', async (e) => {
e.preventDefault();
alert('Prompt published! (Demo)');
// Here you would normally send data to a backend (including the image file)
const status = document.getElementById('create-status');
const submitBtn = document.querySelector('.submit-btn');
status.textContent = editPromptId ? 'Saving...' : 'Publishing...';
submitBtn.disabled = true;
try {
const isPaid = paidBtn.classList.contains('active');
const price = isPaid ? Number(priceInput.value || 0) : null;
const payload = {
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
category: document.getElementById('category').value,
content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null,
price,
subscriptionTier: null,
slug: null
};
const response = await fetch(editPromptId ? `/api/v1/prompts/${editPromptId}` : '/api/v1/prompts', {
method: editPromptId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) {
const error = await response.text();
throw new Error(getCreateErrorMessage(error, response.status));
}
const prompt = await response.json();
location.href = `/post-detail?id=${prompt.id}`;
} catch (error) {
status.textContent = error.message;
submitBtn.disabled = false;
}
});
function getCreateErrorMessage(errorText, status) {
if (!errorText) return `Server error ${status}`;
try {
const error = JSON.parse(errorText);
const messages = error.errors
? Object.values(error.errors).flat()
: [error.title || errorText];
return messages.join(' ');
} catch {
return errorText;
}
}
// Cancel button (go back)
document.querySelector('.cancel-btn').addEventListener('click', () => {
window.history.back();
});
// Fetch sidebar and topbar
fetch('../html/sidebar.html')
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
@ -173,9 +296,11 @@
if (createLink) createLink.classList.add('active');
});
fetch('../html/topbar.html')
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories().then(loadPromptForEdit);
</script>
</body>
</html>
</html>

View File

@ -16,4 +16,26 @@
body {
background: var(--bg);
color: var(--text);
}
/* Form errors */
.form-error {
color: red;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-error ul {
list-style: none;
padding-left: 0;
list-style: '*';
}
.form-error li {
margin-bottom: 0.25rem;
}
.form-error li .error {
color: red;
font-style: italic;
}

View File

@ -137,6 +137,16 @@
.follow-btn:hover {
opacity: 0.85;
}
.follow-btn.following {
background: transparent;
border: 2px solid #94a3b8;
color: #64748b;
}
.follow-btn.following:hover {
border-color: #ef4444;
color: #ef4444;
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {

View File

@ -71,16 +71,18 @@
.post-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
transition:
transform 0.2s,
box-shadow 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
/* Post Header */
@ -165,6 +167,15 @@
.action-btn i {
font-size: 1.1rem;
}
.action-btn.active {
font-weight: 700;
}
.like-btn.active {
color: #ef4444;
}
.save-btn.active {
color: #f59e0b;
}
.like-btn:hover {
color: #ef4444;
}
@ -198,6 +209,49 @@
}
}
/* Avatar initials fallback */
.post-avatar-initials {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary, #6366f1);
color: #fff;
font-weight: 700;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Locked post */
.post-locked {
opacity: 0.75;
}
.post-image-locked {
filter: blur(6px);
pointer-events: none;
}
.post-locked-msg {
color: #94a3b8;
font-size: 0.85rem;
margin: 6px 0 0;
}
/* Star rating */
.post-rating {
display: flex;
align-items: center;
gap: 3px;
color: #f59e0b;
font-size: 0.85rem;
margin-top: 6px;
}
.post-rating span {
color: #64748b;
margin-left: 4px;
}
@media (max-width: 480px) {
.feed-main {
padding: 12px !important;
@ -214,4 +268,4 @@
.post-actions {
padding: 8px 10px 10px 10px;
}
}
}

View File

@ -99,15 +99,17 @@
.prompt-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
transition:
transform 0.2s,
box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.prompt-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
.prompt-img {
@ -171,7 +173,8 @@
gap: 12px;
margin-top: 8px;
}
.buy-btn, .details-btn {
.buy-btn,
.details-btn {
flex: 1;
border: none;
border-radius: 14px;
@ -189,7 +192,8 @@
background: #f1f5f9;
color: #334155;
}
.buy-btn:hover, .details-btn:hover {
.buy-btn:hover,
.details-btn:hover {
opacity: 0.85;
}
@ -222,4 +226,28 @@
.prompt-actions {
flex-direction: column;
}
}
}
/* Payment method buttons */
.pay-method-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
text-align: left;
}
.pay-method-btn:hover {
border-color: #6366f1;
background: #f5f3ff;
}

View File

@ -40,6 +40,27 @@
border-radius: 14px !important;
}
.profile-tab {
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 10px 0;
border-bottom: 2px solid transparent;
font-size: 1rem;
}
.profile-tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.profile-tab:hover:not(.active) {
color: #334155;
}
/* Prompt cards: rounded corners */
.profile-main section > div {
border-radius: 18px !important;
@ -91,4 +112,4 @@ nav {
.profile-main section:last-child {
grid-template-columns: 1fr !important;
}
}
}

View File

@ -100,13 +100,22 @@
padding-top: 16px;
}
.sidebar-bottom form {
margin: 0;
}
.sidebar-logout {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
border: 0;
background: transparent;
cursor: pointer;
text-decoration: none;
color: #64748b;
font: inherit;
font-weight: 600;
transition: background 0.2s ease;
}
@ -167,4 +176,4 @@
.sidebar-logout {
padding: 10px;
}
}
}

View File

@ -0,0 +1,284 @@
<!-- OnlyPrompt - Feed page:
- Social media style post feed with likes, comments, saves, and share actions (following/foryou tabs) -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Feed</title>
<link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/dashboard.css" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
</head>
<body>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="feed-main">
<!-- Optional: Feed Header -->
<div class="feed-header">
<h1>Feed</h1>
<p>Latest prompts and inspiration from creators you follow</p>
</div>
<!-- Filter Buttons -->
<div class="filter-buttons">
<button
class="filter-btn active"
data-sort="date"
data-ascending="false"
>
Recent
</button>
<button
class="filter-btn"
data-sort="rating"
data-ascending="false"
>
Top Rated
</button>
<button class="filter-btn" data-sort="date" data-ascending="true">
Oldest
</button>
</div>
<!-- Posts Grid -->
<div class="posts-grid" id="posts-grid"></div>
<!-- Empty State -->
<div
id="feed-empty"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #64748b;
"
>
<i
class="bi bi-inbox"
style="font-size: 3rem; display: block; margin-bottom: 16px"
></i>
<h3 style="margin-bottom: 8px">No posts yet</h3>
<p>Follow some creators to see their prompts here.</p>
</div>
<!-- Error State -->
<div
id="feed-error"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #ef4444;
"
>
<i
class="bi bi-exclamation-circle"
style="font-size: 3rem; display: block; margin-bottom: 16px"
></i>
<h3 style="margin-bottom: 8px">Could not load feed</h3>
<p id="feed-error-msg"></p>
</div>
</main>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ──────────────────────────────────────────────
fetch("/sidebar.html")
.then((r) => r.text())
.then((data) => {
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((link) => link.classList.remove("active"));
const firstLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[0];
if (firstLink) firstLink.classList.add("active");
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
// ── Feed ──────────────────────────────────────────────────────────
const grid = document.getElementById("posts-grid");
const emptyEl = document.getElementById("feed-empty");
const errorEl = document.getElementById("feed-error");
const errorMsg = document.getElementById("feed-error-msg");
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function renderStars(rating) {
if (rating == null) return "";
const stars = Math.round(rating);
return `<span class="post-rating" title="${rating.toFixed(1)} / 5">
${'<i class="bi bi-star-fill"></i>'.repeat(stars)}${'<i class="bi bi-star"></i>'.repeat(5 - stars)}
<span>${rating.toFixed(1)}</span>
</span>`;
}
function feedImg(id) {
return `/images/content/feed${(parseInt(id.slice(-1), 16) % 4) + 1}.png`;
}
function profileUrl(userId) {
return `/profile.html?id=${encodeURIComponent(userId)}`;
}
function renderCard(prompt) {
const locked = !prompt.canAccess;
const liked = prompt.isLiked;
const saved = prompt.isSaved;
return `
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='${profileUrl(prompt.creatorId)}'">
<div class="post-header">
<img class="post-avatar" src="${prompt.creatorAvatarUrl || '../images/content/cat.png'}" alt="${prompt.creatorName}">
<div class="post-author">
<span class="post-name">${prompt.creatorName}</span>
</div>
<span class="post-date">${timeAgo(prompt.timeStamp)}</span>
</div>
<div class="post-content">
${prompt.exampleImageUrl ? `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
<h3 class="post-title" style="margin-top:10px">${prompt.title}</h3>
<p class="post-description">${prompt.description || ''}</p>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''}
${renderStars(prompt.averageRating)}
</div>
<div class="post-actions">
<button class="action-btn like-btn ${liked ? 'active' : ''}" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? 'bi-heart-fill' : 'bi-heart'}"></i> <span>Like (${prompt.likeCount || 0})</span></button>
<button class="action-btn comment-btn" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat"></i> <span>Review</span></button>
<button class="action-btn share-btn" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share"></i> <span>Share</span></button>
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>Save (${prompt.saveCount || 0})</span></button>
</div>
</div>`;
}
window.toggleLike = async function(event, id, isLiked) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/likes`, {
method: isLiked ? "DELETE" : "PUT",
credentials: "same-origin"
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.toggleFeedState = function(event, type, id) {
event.stopPropagation();
const key = `prompt-${type}-${id}`;
const next = localStorage.getItem(key) !== "true";
localStorage.setItem(key, next);
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.toggleSave = async function(event, id, isSaved) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/saves`, {
method: isSaved ? "DELETE" : "PUT",
credentials: "same-origin"
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.sharePrompt = function(event, id) {
event.stopPropagation();
navigator.clipboard.writeText(`${location.origin}/post-detail?id=${id}`);
};
async function loadFeed(sortBy = "date", ascending = false) {
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
try {
const res = await fetch(
`/api/v1/feed?sortBy=${sortBy}&ascending=${ascending}&limit=20`,
);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
const prompts = await res.json();
if (prompts.length === 0) {
emptyEl.style.display = "block";
return;
}
grid.innerHTML = prompts.map(renderCard).join("");
} catch (e) {
errorEl.style.display = "block";
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ────────────────────────────────────────────────
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document
.querySelectorAll(".filter-btn")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
loadFeed(btn.dataset.sort, btn.dataset.ascending === "true");
});
});
// Initial load
loadFeed();
</script>
</body>
</html>

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

Some files were not shown because too many files have changed in this diff Show More