Compare commits
No commits in common. "539c395beb43cdc8f77e85a2ce0ad71175a0a83a" and "6ea5a386cf31e9b35b9a85de80a1567e75eba312" have entirely different histories.
539c395beb
...
6ea5a386cf
30
.dockerignore
Normal 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
@ -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
@ -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.
|
||||
4
OnlyPrompt.Backend/ApiModels/Auth/Models.cs
Normal file
@ -0,0 +1,4 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Auth
|
||||
{
|
||||
public record ApiUser(Guid Id, string UserName, string Email, string[] Roles);
|
||||
}
|
||||
8
OnlyPrompt.Backend/ApiModels/Auth/Requests.cs
Normal 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);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Category/Models.cs
Normal 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);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Category/Requests.cs
Normal 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);
|
||||
}
|
||||
8
OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public enum FeedSortType
|
||||
{
|
||||
Date,
|
||||
Rating
|
||||
}
|
||||
}
|
||||
8
OnlyPrompt.Backend/ApiModels/Prompt/Models.cs
Normal 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);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs
Normal 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);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Subscription/Models.cs
Normal 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);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs
Normal 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);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs
Normal 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);
|
||||
}
|
||||
8
OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using OnlyPrompt.Backend.ApiModels.Validators;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlyPrompt.Backend.ApiModels.UserProfile
|
||||
{
|
||||
public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? UserName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
|
||||
public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating);
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
83
OnlyPrompt.Backend/Controllers/AdminController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
121
OnlyPrompt.Backend/Controllers/AuthController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
45
OnlyPrompt.Backend/Controllers/BaseController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
OnlyPrompt.Backend/Controllers/CategoryController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
79
OnlyPrompt.Backend/Controllers/FeedController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
168
OnlyPrompt.Backend/Controllers/ProfileController.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
401
OnlyPrompt.Backend/Controllers/PromptController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
180
OnlyPrompt.Backend/Controllers/SubscriptionController.cs
Normal file
@ -0,0 +1,180 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.ApiModels.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
OnlyPrompt.Backend/Database/Core/EntityBase.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
7
OnlyPrompt.Backend/Database/Core/IEntity.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace OnlyPrompt.Backend.Database.Core
|
||||
{
|
||||
public interface IEntity
|
||||
{
|
||||
public Guid Id { get; }
|
||||
}
|
||||
}
|
||||
7
OnlyPrompt.Backend/Database/Core/IHasSlug.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace OnlyPrompt.Backend.Database.Core
|
||||
{
|
||||
public interface IHasSlug
|
||||
{
|
||||
public string Slug { get; set; }
|
||||
}
|
||||
}
|
||||
8
OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace OnlyPrompt.Backend.Database.Core
|
||||
{
|
||||
public interface ITrackableEntity
|
||||
{
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
14
OnlyPrompt.Backend/Database/ModelConstants.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
OnlyPrompt.Backend/Database/Models/CategoryModel.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
15
OnlyPrompt.Backend/Database/Models/PromptLikeModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
52
OnlyPrompt.Backend/Database/Models/PromptModel.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
15
OnlyPrompt.Backend/Database/Models/PromptSaveModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
34
OnlyPrompt.Backend/Database/Models/ReviewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
32
OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
30
OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
31
OnlyPrompt.Backend/Database/Models/UserModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
29
OnlyPrompt.Backend/Database/Models/UserProfileModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
123
OnlyPrompt.Backend/Database/OnlyPromptContext.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
31
OnlyPrompt.Backend/Dockerfile
Normal 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"]
|
||||
419
OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
300
OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
419
OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
51
OnlyPrompt.Backend/Migrations/20260530123000_PromptLikes.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
51
OnlyPrompt.Backend/Migrations/20260530124000_PromptSaves.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
424
OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
OnlyPrompt.Backend/OnlyPrompt.Backend.csproj
Normal 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>
|
||||
6
OnlyPrompt.Backend/OnlyPrompt.Backend.http
Normal file
@ -0,0 +1,6 @@
|
||||
@OnlyPrompt.Backend_HostAddress = http://localhost:5093
|
||||
|
||||
GET {{OnlyPrompt.Backend_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
211
OnlyPrompt.Backend/Program.cs
Normal 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();
|
||||
}
|
||||
25
OnlyPrompt.Backend/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
15
OnlyPrompt.Backend/Services/Jwt/ITokenService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
72
OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
111
OnlyPrompt.Backend/Utils/AutoMapperSetup.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
OnlyPrompt.Backend/Utils/AutomapperExtensions.cs
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
28
OnlyPrompt.Backend/Utils/EntityExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
80
OnlyPrompt.Backend/Utils/Extensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
OnlyPrompt.Backend/Utils/Identifier.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
OnlyPrompt.Backend/Utils/SlugHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
OnlyPrompt.Backend/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
OnlyPrompt.Backend/appsettings.json
Normal 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
@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
OnlyPrompt.Frontend/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.indentSize": 2
|
||||
}
|
||||
@ -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>
|
||||
204
OnlyPrompt.Frontend/community.html
Normal 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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
284
OnlyPrompt.Frontend/dashboard.html
Normal 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>
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 579 KiB After Width: | Height: | Size: 579 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 660 KiB After Width: | Height: | Size: 660 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 659 KiB After Width: | Height: | Size: 659 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 868 KiB After Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |