├── .dockerignore ├── .gitignore ├── AkilliPrompt.sln ├── LICENSE.md ├── README.md ├── logos ├── logo_240x202.png └── yazilim_academy_logo_320.png └── src ├── AkilliPrompt.Domain ├── AkilliPrompt.Domain.csproj ├── Common │ ├── EntityBase.cs │ ├── ICreatedByEntity.cs │ ├── IDomainEvent.cs │ └── IModifiedByEntity.cs ├── Constants │ └── RoleConstants.cs ├── Entities │ ├── Category.cs │ ├── Placeholder.cs │ ├── Prompt.cs │ ├── PromptCategory.cs │ ├── PromptComment.cs │ ├── RefreshToken.cs │ ├── UserFavoritePrompt.cs │ ├── UserLikePrompt.cs │ └── UserSocialMediaAccount.cs ├── Enums │ └── SocialMediaType.cs ├── Helpers │ └── IpHelper.cs ├── Identity │ ├── ApplicationRole.cs │ ├── ApplicationRoleClaim.cs │ ├── ApplicationUser.cs │ ├── ApplicationUserClaim.cs │ ├── ApplicationUserLogin.cs │ ├── ApplicationUserRole.cs │ └── ApplicationUserToken.cs ├── Settings │ ├── AzureKeyVaultSettings.cs │ ├── CloudflareR2Settings.cs │ ├── GoogleAuthSettings.cs │ └── JwtSettings.cs └── ValueObjects │ ├── AccessToken.cs │ ├── FullName.cs │ ├── RefreshToken.cs │ └── ValidationError.cs ├── AkilliPrompt.Persistence ├── AkilliPrompt.Persistence.csproj ├── DependencyInjection.cs ├── EntityFramework │ ├── Configurations │ │ ├── ApplicationRoleClaimConfiguration.cs │ │ ├── ApplicationRoleConfiguration.cs │ │ ├── ApplicationUserClaimConfiguration.cs │ │ ├── ApplicationUserConfiguration.cs │ │ ├── ApplicationUserLoginConfiguration.cs │ │ ├── ApplicationUserRoleConfiguration.cs │ │ ├── ApplicationUserTokenConfiguration.cs │ │ ├── CategoryConfiguration.cs │ │ ├── PlaceholderConfiguration.cs │ │ ├── PromptCategoryConfiguration.cs │ │ ├── PromptCommentConfiguration.cs │ │ ├── PromptConfiguration.cs │ │ ├── UserFavoritePromptConfiguration.cs │ │ ├── UserLikePromptConfiguration.cs │ │ └── UserSocialMediaAccountConfiguration.cs │ ├── Contexts │ │ ├── ApplicationDbContext.cs │ │ └── ApplicationDbContextFactory.cs │ ├── Extensions │ │ └── ConvertionExtensions.cs │ ├── Interceptors │ │ └── EntityInterceptor.cs │ ├── Migrations │ │ ├── 20241201192259_InitialCreate.Designer.cs │ │ ├── 20241201192259_InitialCreate.cs │ │ └── ApplicationDbContextModelSnapshot.cs │ └── Seeders │ │ └── ApplicationRoleSeeder.cs └── Services │ └── ICurrentUserService.cs └── AkilliPrompt.WebApi ├── AkilliPrompt.WebApi.csproj ├── Attributes ├── CacheKeyPartAttribute.cs └── CacheOptionsAttribute.cs ├── Behaviors ├── CachingBehavior.cs └── ValidationBehavior.cs ├── Common └── FluentValidation │ └── EntityExistsValidator.cs ├── Configuration └── SwaggerConfiguration.cs ├── DependencyInjection.cs ├── Dockerfile ├── Extensions └── ApplicationBuilderExtensions.cs ├── Filters ├── GlobalExceptionFilter.cs └── SwaggerJsonIgnoreFilter.cs ├── Helpers ├── CacheKeysHelper.cs └── MessageHelper.cs ├── Interfaces ├── ICacheable.cs └── IPaginated.cs ├── Models ├── PaginatedList.cs └── ResponseDto.cs ├── Options └── ConfigureSwaggerOptions.cs ├── Program.cs ├── Properties └── launchSettings.json ├── Services ├── CacheInvalidator.cs ├── CacheKeyFactory.cs ├── CurrentUserManager.cs ├── ExistenceManager.cs ├── IExistenceService.cs ├── JwtManager.cs └── R2ObjectStorageManager.cs ├── V1 ├── Auth │ ├── AuthController.cs │ └── Commands │ │ └── GoogleLogin │ │ ├── GoogleLoginCommand.cs │ │ ├── GoogleLoginCommandHandler.cs │ │ ├── GoogleLoginCommandValidator.cs │ │ └── GoogleLoginDto.cs ├── Categories │ ├── CategoriesController.cs │ ├── Commands │ │ ├── Create │ │ │ ├── CreateCategoryCommand.cs │ │ │ ├── CreateCategoryCommandHandler.cs │ │ │ └── CreateCategoryCommandValidator.cs │ │ ├── Delete │ │ │ ├── DeleteCategoryCommand.cs │ │ │ ├── DeleteCategoryCommandHandler.cs │ │ │ └── DeleteCategoryValidator.cs │ │ └── Update │ │ │ ├── UpdateCategoryCommand.cs │ │ │ ├── UpdateCategoryCommandHandler.cs │ │ │ └── UpdateCategoryCommandValidator.cs │ └── Queries │ │ ├── GetAll │ │ ├── GetAllCategoriesDto.cs │ │ ├── GetAllCategoriesQuery.cs │ │ └── GetAllCategoriesQueryHandler.cs │ │ └── GetById │ │ ├── GetByIdCategoryDto.cs │ │ ├── GetByIdCategoryQuery.cs │ │ ├── GetByIdCategoryQueryHandler.cs │ │ └── GetByIdCategoryQueryValidator.cs ├── PromptComments │ ├── PromptCommentsController.cs │ └── Queries │ │ └── GetAll │ │ ├── GetAllPromptCommentsDto.cs │ │ ├── GetAllPromptCommentsQuery.cs │ │ ├── GetAllPromptCommentsQueryHandler.cs │ │ └── GetAllPromptCommentsQueryValidator.cs └── Prompts │ ├── Commands │ ├── Create │ │ ├── CreatePromptCommand.cs │ │ ├── CreatePromptCommandHandler.cs │ │ └── CreatePromptCommandValidator.cs │ └── Delete │ │ ├── DeletePromptCommand.cs │ │ ├── DeletePromptCommandHandler.cs │ │ └── DeletePromptCommandValidator.cs │ ├── PromptsController.cs │ ├── Queries │ ├── GetAll │ │ ├── GetAllPromptsDto.cs │ │ ├── GetAllPromptsQuery.cs │ │ ├── GetAllPromptsQueryHandler.cs │ │ └── GetAllPromptsQueryValidator.cs │ └── GetById │ │ ├── GetPromptByIdDto.cs │ │ ├── GetPromptByIdQuery.cs │ │ ├── GetPromptByIdQueryHandler.cs │ │ └── GetPromptByIdQueryValidator.cs │ └── UpdatePromptDto.cs ├── appsettings.Development.json └── appsettings.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md 26 | !**/.gitignore 27 | !.git/HEAD 28 | !.git/config 29 | !.git/packed-refs 30 | !.git/refs/heads/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUnit 46 | *.VisualState.xml 47 | TestResult.xml 48 | nunit-*.xml 49 | 50 | # Build Results of an ATL Project 51 | [Dd]ebugPS/ 52 | [Rr]eleasePS/ 53 | dlldata.c 54 | 55 | # Benchmark Results 56 | BenchmarkDotNet.Artifacts/ 57 | 58 | # .NET Core 59 | project.lock.json 60 | project.fragment.lock.json 61 | artifacts/ 62 | 63 | # StyleCop 64 | StyleCopReport.xml 65 | 66 | # Files built by Visual Studio 67 | *_i.c 68 | *_p.c 69 | *_h.h 70 | *.ilk 71 | *.meta 72 | *.obj 73 | *.iobj 74 | *.pch 75 | *.pdb 76 | *.ipdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *_wpftmp.csproj 87 | *.log 88 | *.vspscc 89 | *.vssscc 90 | .builds 91 | *.pidb 92 | *.svclog 93 | *.scc 94 | 95 | # Chutzpah Test files 96 | _Chutzpah* 97 | 98 | # Visual C++ cache files 99 | ipch/ 100 | *.aps 101 | *.ncb 102 | *.opendb 103 | *.opensdf 104 | *.sdf 105 | *.cachefile 106 | *.VC.db 107 | *.VC.VC.opendb 108 | 109 | # Visual Studio profiler 110 | *.psess 111 | *.vsp 112 | *.vspx 113 | *.sap 114 | 115 | # Visual Studio Trace Files 116 | *.e2e 117 | 118 | # TFS 2012 Local Workspace 119 | $tf/ 120 | 121 | # Guidance Automation Toolkit 122 | *.gpState 123 | 124 | # ReSharper is a .NET coding add-in 125 | _ReSharper*/ 126 | *.[Rr]e[Ss]harper 127 | *.DotSettings.user 128 | 129 | # JustCode is a .NET coding add-in 130 | .JustCode 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | 351 | src/AkilliPrompt.WebApi/Logs/ 352 | -------------------------------------------------------------------------------- /AkilliPrompt.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.12.35514.174 d17.12 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3BB6ED29-C82B-4633-A40D-7FDB570168DE}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{17C870BC-239B-4DEA-94C7-6D9DE7887C3A}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AkilliPrompt.Domain", "src\AkilliPrompt.Domain\AkilliPrompt.Domain.csproj", "{C8488BCA-BC9F-484A-9450-4DF494B2E6C7}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AkilliPrompt.Persistence", "src\AkilliPrompt.Persistence\AkilliPrompt.Persistence.csproj", "{3400E90E-7504-40DD-A242-A3B291B3DB05}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AkilliPrompt.WebApi", "src\AkilliPrompt.WebApi\AkilliPrompt.WebApi.csproj", "{2A6044C5-6A4C-4E38-B5D8-2581523B017A}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {C8488BCA-BC9F-484A-9450-4DF494B2E6C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {C8488BCA-BC9F-484A-9450-4DF494B2E6C7}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {C8488BCA-BC9F-484A-9450-4DF494B2E6C7}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {C8488BCA-BC9F-484A-9450-4DF494B2E6C7}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {3400E90E-7504-40DD-A242-A3B291B3DB05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {3400E90E-7504-40DD-A242-A3B291B3DB05}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {3400E90E-7504-40DD-A242-A3B291B3DB05}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {3400E90E-7504-40DD-A242-A3B291B3DB05}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {2A6044C5-6A4C-4E38-B5D8-2581523B017A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {2A6044C5-6A4C-4E38-B5D8-2581523B017A}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {2A6044C5-6A4C-4E38-B5D8-2581523B017A}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {2A6044C5-6A4C-4E38-B5D8-2581523B017A}.Release|Any CPU.Build.0 = Release|Any CPU 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(NestedProjects) = preSolution 39 | {C8488BCA-BC9F-484A-9450-4DF494B2E6C7} = {3BB6ED29-C82B-4633-A40D-7FDB570168DE} 40 | {3400E90E-7504-40DD-A242-A3B291B3DB05} = {3BB6ED29-C82B-4633-A40D-7FDB570168DE} 41 | {2A6044C5-6A4C-4E38-B5D8-2581523B017A} = {3BB6ED29-C82B-4633-A40D-7FDB570168DE} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Yazılım Academy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Akıllı Prompt Backend 2 | 3 |
4 | Yazılım Academy Logo 5 |    6 | Akıllı Prompt Logo 7 |
8 |

9 | 10 | Merhaba, Türkiye'nin geliştirici topluluğu! Şu an üzerinde çalıştığımız "Akıllı Prompt" projesine hoşgeldiniz. Bu proje, ChatGPT, Claude, Gemini, MidJourney gibi farklı yapay zeka modellerinin kullanıcıları için bir "prompt" kütüphanesi ve topluluğu yaratmayı amaçlıyor. Bizim amacımız, bu alandaki geliştiricileri ve meraklıları bir araya getirerek, paylaşım ve dayanışma ruhunu canlı tutmak. Projemizi açık kaynak hale getirerek Türkiye geliştirici topluluğuna katkıda bulunmak istiyoruz. 🤝 11 | 12 | ## Proje Hakkında 13 | 14 | Bu proje, Minimum Viable Product (MVP) anlayışıyla geliştirilmekte olup, öğren, inşa et ve tekrar et döngüsüyle ilerlemeyi hedefliyoruz. Kullanıcı geri bildirimlerine dayalı olarak sürekli iyileştirme ve büyüme odaklı bir yaklaşımla hareket ediyoruz. 15 | 16 | Akıllı Prompt, kullanıcıların farklı yapay zeka modelleriyle kullanabileceği etkili "prompt"lar oluşturabileceği bir SaaS uygulamasıdır. Bu uygulama, hem ücretli bir hizmet sunacak hem de arka uç ve ön uç tarafından açık kaynak kodu sunarak geliştiricilere açık olacak. Bu repo, projenin .NET 9 ve ASP.NET Core Web API kullanılarak geliştirilmiş arka uç (backend) bileşenini içermektedir. 17 | 18 | ## Mimari 19 | 20 | Proje "Clean Architecture" prensiplerine uygun olarak tasarlanmıştır ve aşağıdaki katmanlardan oluşmaktadır: 21 | 22 | - **Domain**: Uygulamanın temel iş mantığını ve kurallarını barındıran katmandır. 23 | - **Persistence**: Veri erişimi ve veritabanı işlemlerini yürüten katmandır. Entity Framework Core kullanılmaktadır. 24 | - **Web API**: RESTful API'leri barındıran ve kullanıcı taleplerine cevap veren katmandır. 25 | 26 | ## Kurulum 27 | 28 | Projeyi kendi bilgisayarınızda çalıştırmak için aşağıdaki adımları izleyebilirsiniz: 29 | 30 | 1. **Depoyu Klonlayın** 31 | 32 | ```sh 33 | git clone https://github.com/YazilimAcademy/AkilliPrompt.git 34 | cd AkilliPrompt 35 | ``` 36 | 37 | 2. **Bağımlılıkları Yükleyin** 38 | 39 | - Proje .NET 9 kullanıyor, bu nedenle .NET 9 SDK'sını kurduğunuzdan emin olun. 40 | - Entity Framework Core, proje bağımlılıklarından biridir. Aşağıdaki komutu kullanarak veritabanı güncellemelerini uygulayabilirsiniz: 41 | ```sh 42 | dotnet ef database update 43 | ``` 44 | 45 | 3. **Uygulamayı Çalıştırın** 46 | 47 | ```sh 48 | dotnet run --project AkilliPrompt.WebApi 49 | ``` 50 | 51 | ## Katkıda Bulunma 52 | 53 | Bu projenin gelişiminde katkıda bulunmak isteyen herkesi bekliyoruz! Katkıda bulunmak için: 54 | 55 | 1. Bir "issue" oluşturarak hataları bildirin veya önerilerde bulunun. 56 | 2. Yeni bir özellik eklemek ya da hata düzeltmek istiyorsanız, bir "pull request" oluşturabilirsiniz. 57 | 3. Katkı sağlarken, lütfen "Clean Code" prensiplerine ve projenin kod standartlarına uygun şekilde kod yazmaya dikkat edin. 58 | 59 | ## Lisans 60 | 61 | Bu proje, MIT Lisansı altında sunulmuştur. Daha fazla bilgi için `LICENSE` dosyasına göz atabilirsiniz. 62 | 63 | Hep birlikte Türkiye'deki yazılım geliştirici topluluğunu daha da ileri taşıyalım! 64 | 65 | ## Topluluk ve İletişim 66 | 67 | Akıllı Prompt projesinin geliştirilmesi ve büyütülmesi sürecinde siz de yer almak ister misiniz? Gelin, birlikte öğrenip gelişelim! 🤝 68 | 69 | - **Discord**: Topluluğumuza katılın ve diğer geliştiricilerle sohbet edin. [Discord Bağlantısı](https://discord.gg/yazilimacademy) 70 | - **Yazılım Academy Web**: Daha fazla bilgi ve kaynak için [Yazılım Academy Web Sitesi](https://yazilim.academy/) 71 | - **YouTube**: Eğitim videoları ve duyurular için [YouTube Kanalımız](https://www.youtube.com/@yazilimacademy) 72 | 73 | 74 | 75 | 76 | ### Oricin ve Yazılım Academy’deki ekip arkadaşlarımıza çok teşekkür ederiz. 👇 77 | 78 | Oricin 79 | altudev 80 | EmirhanKara 81 | NNakreSS 82 | ladrons 83 | safakyilmaz-mis 84 | alihangudenoglu 85 | gurkantngl 86 | baharsevinti 87 | saliharana 88 | haticebulbul 89 | Taiizor 90 | 91 | ## Teşekkürler 92 | Projemize gösterdiğiniz ilgi ve desteğiniz için çok teşekkür ederiz. Birlikte daha büyük bir topluluk oluşturabilir ve daha faydalı projeler geliştirebiliriz. 🙏 93 | -------------------------------------------------------------------------------- /logos/logo_240x202.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yazilimacademy/akilliprompt-backend/d0d8c7f7017d0677ff5c41f03516177f38f33f2a/logos/logo_240x202.png -------------------------------------------------------------------------------- /logos/yazilim_academy_logo_320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yazilimacademy/akilliprompt-backend/d0d8c7f7017d0677ff5c41f03516177f38f33f2a/logos/yazilim_academy_logo_320.png -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/AkilliPrompt.Domain.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Common/EntityBase.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Common; 2 | 3 | public abstract class EntityBase : ICreatedByEntity, IModifiedByEntity 4 | { 5 | public Guid Id { get; set; } 6 | 7 | public string? CreatedByUserId { get; set; } 8 | public DateTimeOffset CreatedAt { get; set; } 9 | 10 | public string? ModifiedByUserId { get; set; } 11 | public DateTimeOffset? ModifiedAt { get; set; } 12 | 13 | private readonly List _domainEvents = []; 14 | public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); 15 | 16 | protected void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); 17 | protected void ClearDomainEvents() => _domainEvents.Clear(); 18 | } 19 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Common/ICreatedByEntity.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Common; 2 | 3 | public interface ICreatedByEntity 4 | { 5 | string? CreatedByUserId { get; set; } 6 | DateTimeOffset CreatedAt { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Common/IDomainEvent.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace AkilliPrompt.Domain.Common; 4 | 5 | public interface IDomainEvent : INotification 6 | { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Common/IModifiedByEntity.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Common; 2 | 3 | public interface IModifiedByEntity 4 | { 5 | string? ModifiedByUserId { get; set; } 6 | DateTimeOffset? ModifiedAt { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Constants/RoleConstants.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Constants; 2 | 3 | public static class RoleConstants 4 | { 5 | public const string UserRole = "User"; 6 | public const string AdminRole = "Admin"; 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/Category.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using TSID.Creator.NET; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | 6 | public sealed class Category : EntityBase 7 | { 8 | public string Name { get; private set; } 9 | public string Description { get; private set; } 10 | 11 | public ICollection PromptCategories { get; set; } = []; 12 | 13 | public static Category Create(string name, string description) 14 | { 15 | return new Category 16 | { 17 | Id = Guid.CreateVersion7(), 18 | Name = name, 19 | Description = description, 20 | }; 21 | } 22 | 23 | public void Update(string name, string description) 24 | { 25 | Name = name; 26 | Description = description; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/Placeholder.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using TSID.Creator.NET; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | 6 | public sealed class Placeholder : EntityBase 7 | { 8 | public string Name { get; set; } 9 | public Guid PromptId { get; set; } 10 | public Prompt Prompt { get; set; } 11 | 12 | public static Placeholder Create(string name, Guid promptId) 13 | { 14 | return new Placeholder 15 | { 16 | Id = Guid.CreateVersion7(), 17 | Name = name, 18 | PromptId = promptId 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/Prompt.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Domain.Identity; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | 6 | public sealed class Prompt : EntityBase 7 | { 8 | public string Title { get; private set; } 9 | public string Description { get; private set; } 10 | public string Content { get; private set; } 11 | 12 | public string? ImageUrl { get; private set; } 13 | public bool IsActive { get; private set; } 14 | public int LikeCount { get; private set; } 15 | 16 | public Guid CreatorId { get; private set; } 17 | public ApplicationUser Creator { get; private set; } 18 | 19 | public ICollection PromptCategories { get; private set; } = []; 20 | public ICollection UserFavoritePrompts { get; set; } = []; 21 | public ICollection UserLikePrompts { get; set; } = []; 22 | public ICollection Placeholders { get; set; } = []; 23 | public ICollection PromptComments { get; set; } = []; 24 | 25 | 26 | public static Prompt Create(string title, string description, string content, bool isActive, Guid creatorId) 27 | { 28 | return new Prompt 29 | { 30 | Id = Guid.CreateVersion7(), 31 | Title = title, 32 | Description = description, 33 | Content = content, 34 | IsActive = isActive, 35 | CreatorId = creatorId 36 | }; 37 | } 38 | 39 | public void SetImageUrl(string imageUrl) 40 | { 41 | ImageUrl = imageUrl; 42 | } 43 | 44 | public void Update(string title, string description, string content, bool isActive) 45 | { 46 | Title = title; 47 | Description = description; 48 | Content = content; 49 | IsActive = isActive; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/PromptCategory.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using TSID.Creator.NET; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | 6 | public sealed class PromptCategory : EntityBase 7 | { 8 | public Guid PromptId { get; set; } 9 | public Prompt Prompt { get; set; } 10 | 11 | public Guid CategoryId { get; set; } 12 | public Category Category { get; set; } 13 | 14 | public static PromptCategory Create(Guid promptId, Guid categoryId) 15 | { 16 | return new PromptCategory 17 | { 18 | Id = Guid.CreateVersion7(), 19 | PromptId = promptId, 20 | CategoryId = categoryId, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/PromptComment.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Domain.Identity; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | 6 | public sealed class PromptComment : EntityBase 7 | { 8 | public int Level { get; set; } 9 | public string Content { get; set; } 10 | 11 | public Guid PromptId { get; set; } 12 | public Prompt Prompt { get; set; } 13 | 14 | public Guid UserId { get; set; } 15 | public ApplicationUser User { get; set; } 16 | 17 | public Guid? ParentCommentId { get; set; } 18 | public PromptComment ParentComment { get; set; } 19 | 20 | public ICollection ChildComments { get; set; } = []; 21 | } 22 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/RefreshToken.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Domain.Identity; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | public class RefreshToken : EntityBase 6 | { 7 | public string Token { get; set; } 8 | public DateTime Expires { get; set; } 9 | public string CreatedByIp { get; set; } 10 | public string SecurityStamp { get; set; } 11 | public DateTime? Revoked { get; set; } 12 | public string? RevokedByIp { get; set; } 13 | public Guid UserId { get; set; } 14 | public virtual ApplicationUser User { get; set; } 15 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/UserFavoritePrompt.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Domain.Identity; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | 6 | public sealed class UserFavoritePrompt : EntityBase 7 | { 8 | public Guid UserId { get; set; } 9 | public ApplicationUser User { get; set; } 10 | 11 | public Guid PromptId { get; set; } 12 | public Prompt Prompt { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/UserLikePrompt.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Domain.Identity; 3 | 4 | namespace AkilliPrompt.Domain.Entities; 5 | 6 | public sealed class UserLikePrompt : EntityBase 7 | { 8 | public Guid UserId { get; set; } 9 | public ApplicationUser User { get; set; } 10 | 11 | public Guid PromptId { get; set; } 12 | public Prompt Prompt { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Entities/UserSocialMediaAccount.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Domain.Enums; 3 | using AkilliPrompt.Domain.Identity; 4 | 5 | namespace AkilliPrompt.Domain.Entities; 6 | 7 | public sealed class UserSocialMediaAccount : EntityBase 8 | { 9 | public SocialMediaType SocialMediaType { get; set; } 10 | public string Url { get; set; } 11 | 12 | public Guid UserId { get; set; } 13 | public ApplicationUser User { get; set; } 14 | } 15 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Enums/SocialMediaType.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Enums; 2 | 3 | public enum SocialMediaType 4 | { 5 | LinkedIn = 1, 6 | X = 2, 7 | GitHub = 3, 8 | Instagram = 4, 9 | Facebook = 5, 10 | TikTok = 6, 11 | YouTube = 7, 12 | Reddit = 8, 13 | Medium = 9, 14 | Website = 10, 15 | } 16 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Helpers/IpHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Sockets; 3 | 4 | namespace AkilliPrompt.Domain.Helpers; 5 | 6 | public static class IpHelper 7 | { 8 | public static string GetIpAddress() 9 | { 10 | var host = Dns.GetHostEntry(Dns.GetHostName()); 11 | 12 | foreach (var ip in host.AddressList) 13 | { 14 | if (ip.AddressFamily == AddressFamily.InterNetwork) 15 | { 16 | return ip.ToString(); 17 | } 18 | } 19 | return string.Empty; 20 | } 21 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Identity/ApplicationRole.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace AkilliPrompt.Domain.Identity; 4 | 5 | public sealed class ApplicationRole : IdentityRole 6 | { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Identity/ApplicationRoleClaim.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace AkilliPrompt.Domain.Identity; 4 | 5 | public sealed class ApplicationRoleClaim : IdentityRoleClaim 6 | { 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Identity/ApplicationUser.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Domain.Entities; 3 | using AkilliPrompt.Domain.ValueObjects; 4 | using Microsoft.AspNetCore.Identity; 5 | 6 | namespace AkilliPrompt.Domain.Identity; 7 | 8 | public sealed class ApplicationUser : IdentityUser, ICreatedByEntity, IModifiedByEntity 9 | { 10 | public FullName FullName { get; set; } 11 | 12 | public DateTimeOffset CreatedAt { get; set; } 13 | public string? CreatedByUserId { get; set; } 14 | 15 | public string? ModifiedByUserId { get; set; } 16 | public DateTimeOffset? ModifiedAt { get; set; } 17 | 18 | public ICollection UserSocialMediaAccounts { get; set; } = []; 19 | public ICollection PromptComments { get; set; } = []; 20 | public ICollection UserFavoritePrompts { get; set; } = []; 21 | public ICollection UserLikePrompts { get; set; } = []; 22 | public ICollection CreatedPrompts { get; set; } = []; 23 | 24 | public static ApplicationUser Create(string email, FullName fullName, bool isEmailConfirmed = false) 25 | { 26 | return new ApplicationUser 27 | { 28 | Id = Guid.CreateVersion7(), 29 | Email = email, 30 | UserName = email, 31 | FullName = fullName, 32 | EmailConfirmed = isEmailConfirmed, 33 | SecurityStamp = Guid.NewGuid().ToString() 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Identity/ApplicationUserClaim.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace AkilliPrompt.Domain.Identity; 4 | 5 | public sealed class ApplicationUserClaim : IdentityUserClaim 6 | { 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Identity/ApplicationUserLogin.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace AkilliPrompt.Domain.Identity; 4 | 5 | public sealed class ApplicationUserLogin : IdentityUserLogin 6 | { 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Identity/ApplicationUserRole.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace AkilliPrompt.Domain.Identity; 4 | 5 | public sealed class ApplicationUserRole : IdentityUserRole 6 | { 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Identity/ApplicationUserToken.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace AkilliPrompt.Domain.Identity; 4 | 5 | public sealed class ApplicationUserToken : IdentityUserToken 6 | { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Settings/AzureKeyVaultSettings.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Settings; 2 | 3 | public sealed record AzureKeyVaultSettings 4 | { 5 | public string Uri { get; init; } 6 | public string TenantId { get; init; } 7 | public string ClientId { get; init; } 8 | public string ClientSecret { get; init; } 9 | } 10 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Settings/CloudflareR2Settings.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Settings; 2 | 3 | public sealed record CloudflareR2Settings 4 | { 5 | public string ServiceUrl { get; set; } 6 | public string AccessKey { get; set; } 7 | public string SecretKey { get; set; } 8 | public string PromptPicsBucketName { get; set; } 9 | public string UserPicsBucketName { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Settings/GoogleAuthSettings.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Settings; 2 | 3 | public sealed record GoogleAuthSettings 4 | { 5 | public string ClientId { get; init; } 6 | 7 | public GoogleAuthSettings(string clientId) 8 | { 9 | ClientId = clientId; 10 | } 11 | 12 | public GoogleAuthSettings() 13 | { 14 | 15 | } 16 | }; -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/Settings/JwtSettings.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Settings; 2 | 3 | public record JwtSettings 4 | { 5 | public string SecretKey { get; set; } 6 | public TimeSpan AccessTokenExpiration { get; set; } 7 | public TimeSpan RefreshTokenExpiration { get; set; } 8 | public string Issuer { get; set; } 9 | public string Audience { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/ValueObjects/AccessToken.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.ValueObjects; 2 | 3 | public sealed record AccessToken 4 | { 5 | public string Value { get; set; } 6 | public DateTime ExpiresOnUtc { get; set; } 7 | public bool IsExpired() => ExpiresOnUtc < DateTime.UtcNow; 8 | 9 | public AccessToken(string value, DateTime expiresOnUtc) 10 | { 11 | Value = value; 12 | ExpiresOnUtc = expiresOnUtc; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/ValueObjects/FullName.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace AkilliPrompt.Domain.ValueObjects 5 | { 6 | /// 7 | /// Represents a user's full name as a value object. 8 | /// 9 | public sealed record FullName 10 | { 11 | private const string NamePattern = @"^[\p{L}][\p{L}'\- ]+$"; // Supports Unicode letters, apostrophes, hyphens, and spaces 12 | private const int MinNameLength = 2; 13 | private const int MaxNameLength = 50; // Adjusted per name part 14 | 15 | /// 16 | /// Gets the first name. 17 | /// 18 | public string FirstName { get; init; } 19 | 20 | /// 21 | /// Gets the last name. 22 | /// 23 | public string LastName { get; init; } 24 | 25 | /// 26 | /// Initializes a new instance of the record. 27 | /// 28 | /// The first name. 29 | /// The last name. 30 | /// Thrown when validation fails. 31 | public FullName(string firstName, string lastName) 32 | { 33 | FirstName = ValidateName(firstName, nameof(firstName)); 34 | LastName = ValidateName(lastName, nameof(lastName)); 35 | } 36 | 37 | /// 38 | /// Validates the name according to defined rules. 39 | /// 40 | /// The name to validate. 41 | /// The parameter name for exception messages. 42 | /// The validated name. 43 | /// Thrown when validation fails. 44 | private static string ValidateName(string name, string paramName) 45 | { 46 | if (string.IsNullOrWhiteSpace(name)) 47 | throw new ArgumentException($"{paramName} cannot be null or whitespace.", paramName); 48 | 49 | if (name.Length < MinNameLength || name.Length > MaxNameLength) 50 | throw new ArgumentException($"{paramName} must be between {MinNameLength} and {MaxNameLength} characters.", paramName); 51 | 52 | if (!Regex.IsMatch(name, NamePattern)) 53 | throw new ArgumentException($"{paramName} contains invalid characters.", paramName); 54 | 55 | // Optional: Capitalize the first letter and lowercase the rest for consistency 56 | return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(name.ToLower()); 57 | } 58 | 59 | /// 60 | /// Creates a from a single string containing the full name. 61 | /// Assumes the last word is the last name and the rest constitute the first name. 62 | /// 63 | /// The full name string. 64 | /// A new instance of . 65 | /// Thrown when validation fails. 66 | public static FullName Create(string fullName) 67 | { 68 | if (string.IsNullOrWhiteSpace(fullName)) 69 | throw new ArgumentException("Full name cannot be null or whitespace.", nameof(fullName)); 70 | 71 | var parts = fullName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); 72 | 73 | if (parts.Length < 2) 74 | throw new ArgumentException("Full name must contain at least first name and last name.", nameof(fullName)); 75 | 76 | string firstName = string.Join(' ', parts.Take(parts.Length - 1)); 77 | string lastName = parts.Last(); 78 | 79 | return new FullName(firstName, lastName); 80 | } 81 | 82 | /// 83 | /// Creates a from separate first and last name strings. 84 | /// 85 | /// The first name. 86 | /// The last name. 87 | /// A new instance of . 88 | public static FullName Create(string firstName, string lastName) 89 | { 90 | return new FullName(firstName, lastName); 91 | } 92 | 93 | /// 94 | /// Explicitly converts a to a string. 95 | /// 96 | /// The full name to convert. 97 | public static explicit operator string(FullName fullName) => fullName.ToString(); 98 | 99 | /// 100 | /// Explicitly converts a string to a . 101 | /// 102 | /// The string to convert. 103 | public static explicit operator FullName(string value) => Create(value); 104 | 105 | /// 106 | /// Returns the full name as a single string. 107 | /// 108 | /// The full name. 109 | public override string ToString() => $"{FirstName} {LastName}"; 110 | 111 | /// 112 | /// Gets the initials of the full name. 113 | /// 114 | /// The initials in the format "F.L.". 115 | public string GetInitials() 116 | { 117 | char firstInitial = FirstName.FirstOrDefault(char.IsLetter); 118 | char lastInitial = LastName.FirstOrDefault(char.IsLetter); 119 | 120 | return $"{char.ToUpper(firstInitial)}.{char.ToUpper(lastInitial)}."; 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/ValueObjects/RefreshToken.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.ValueObjects; 2 | 3 | public sealed record RefreshToken 4 | { 5 | public string Value { get; set; } 6 | public DateTime ExpiresOnUtc { get; set; } 7 | 8 | public bool IsExpired() => ExpiresOnUtc < DateTime.UtcNow; 9 | 10 | public RefreshToken(string value, DateTime expiresOnUtc) 11 | { 12 | Value = value; 13 | ExpiresOnUtc = expiresOnUtc; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Domain/ValueObjects/ValidationError.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.ValueObjects; 2 | 3 | public sealed record ValidationError 4 | { 5 | public string PropertyName { get; init; } 6 | public IEnumerable ErrorMessages { get; init; } 7 | 8 | public ValidationError(string propertyName, IEnumerable errorMessages) 9 | { 10 | PropertyName = propertyName; 11 | ErrorMessages = errorMessages; 12 | } 13 | 14 | public ValidationError(string propertyName, string errorMessage) 15 | : this(propertyName, new List { errorMessage }) 16 | { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/AkilliPrompt.Persistence.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Diagnostics; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | namespace AkilliPrompt.Persistence; 9 | 10 | public static class DependencyInjection 11 | { 12 | public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) 13 | { 14 | var connectionString = configuration.GetConnectionString("DefaultConnection"); 15 | 16 | services.AddDbContext(options => options.UseNpgsql(connectionString, b => b.MigrationsHistoryTable("__ef_migrations_history")).ConfigureWarnings(warnings => warnings.Throw(RelationalEventId.PendingModelChangesWarning))); 17 | 18 | return services; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/ApplicationRoleClaimConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace IAPriceTrackerApp.Persistence.Configurations; 6 | 7 | public sealed class ApplicationRoleClaimConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Primary key 12 | builder.HasKey(rc => rc.Id); 13 | 14 | // Maps to the AspNetRoleClaims table 15 | builder.ToTable("application_role_claims"); 16 | } 17 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/ApplicationRoleConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.Configurations; 6 | 7 | public sealed class ApplicationRoleConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Primary key 12 | builder.HasKey(r => r.Id); 13 | 14 | // Index for "normalized" role name to allow efficient lookups 15 | builder.HasIndex(r => r.NormalizedName).HasDatabaseName("RoleNameIndex").IsUnique(); 16 | 17 | // Maps to the AspNetRoles table 18 | // builder.ToTable("Roles"); 19 | 20 | // A concurrency token for use with the optimistic concurrency checking 21 | builder.Property(r => r.ConcurrencyStamp).IsConcurrencyToken(); 22 | 23 | // Limit the size of columns to use efficient database types 24 | builder.Property(u => u.Name).HasMaxLength(100); 25 | builder.Property(u => u.NormalizedName).HasMaxLength(100); 26 | 27 | // The relationships between Role and other entity types 28 | // Note that these relationships are configured with no navigation properties 29 | 30 | // Each Role can have many entries in the UserRole join table 31 | builder.HasMany().WithOne().HasForeignKey(ur => ur.RoleId).IsRequired(); 32 | 33 | // Each Role can have many associated RoleClaims 34 | builder.HasMany().WithOne().HasForeignKey(rc => rc.RoleId).IsRequired(); 35 | 36 | builder.ToTable("application_roles"); 37 | } 38 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/ApplicationUserClaimConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.Configurations; 6 | 7 | public sealed class ApplicationUserClaimConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Primary key 12 | builder.HasKey(rc => rc.Id); 13 | 14 | // Maps to the AspNetUserClaims table 15 | builder.ToTable("application_user_claims"); 16 | } 17 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/ApplicationUserConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using AkilliPrompt.Domain.ValueObjects; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 5 | 6 | namespace AkilliPrompt.Persistence.Configurations; 7 | 8 | public sealed class ApplicationUserConfiguration : IEntityTypeConfiguration 9 | { 10 | public void Configure(EntityTypeBuilder builder) 11 | { 12 | //Id 13 | builder.HasKey(x => x.Id); 14 | 15 | // Indexes for "normalized" username and email, to allow efficient lookups 16 | builder.HasIndex(u => u.NormalizedUserName).HasDatabaseName("UserNameIndex").IsUnique(); 17 | builder.HasIndex(u => u.NormalizedEmail).HasDatabaseName("EmailIndex"); 18 | 19 | // A concurrency token for use with the optimistic concurrency checking 20 | builder.Property(u => u.ConcurrencyStamp).IsConcurrencyToken(); 21 | 22 | // Limit the size of columns to use efficient database types 23 | builder.Property(u => u.UserName).HasMaxLength(100); 24 | builder.Property(u => u.NormalizedUserName).HasMaxLength(100); 25 | 26 | //Email 27 | builder.Property(u => u.Email).IsRequired(); 28 | builder.HasIndex(user => user.Email).IsUnique(); 29 | builder.Property(u => u.Email).HasMaxLength(150); 30 | builder.Property(u => u.NormalizedEmail).HasMaxLength(150); 31 | 32 | //PhoneNumber 33 | builder.Property(u => u.PhoneNumber).IsRequired(false); 34 | builder.Property(u => u.PhoneNumber).HasMaxLength(20); 35 | 36 | // //FullName 37 | // builder.OwnsOne(x => x.FullName, fullName => 38 | // { 39 | // fullName.Property(x => x.FirstName) 40 | // .IsRequired() 41 | // .HasMaxLength(50) 42 | // .HasColumnName("first_name"); 43 | 44 | // fullName.Property(x => x.LastName) 45 | // .IsRequired() 46 | // .HasMaxLength(50) 47 | // .HasColumnName("last_name"); 48 | 49 | // }); 50 | 51 | builder.Property(x => x.FullName) 52 | .HasConversion(x => x.ToString(), x => FullName.Create(x)); 53 | 54 | 55 | // The relationships between User and other entity types 56 | // Note that these relationships are configured with no navigation properties 57 | 58 | // Each User can have many UserClaims 59 | builder.HasMany().WithOne().HasForeignKey(uc => uc.UserId).IsRequired(); 60 | 61 | // Each User can have many UserLogins 62 | builder.HasMany().WithOne().HasForeignKey(ul => ul.UserId).IsRequired(); 63 | 64 | // Each User can have many UserTokens 65 | builder.HasMany().WithOne().HasForeignKey(ut => ut.UserId).IsRequired(); 66 | 67 | // Each User can have many entries in the UserRole join table 68 | builder.HasMany().WithOne().HasForeignKey(ur => ur.UserId).IsRequired(); 69 | 70 | // Relationships 71 | 72 | // Common Properties 73 | 74 | // Common fields 75 | // CreatedOn 76 | builder.Property(p => p.CreatedAt) 77 | .IsRequired(); 78 | 79 | // CreatedByUserId 80 | builder.Property(p => p.CreatedByUserId) 81 | .IsRequired(false) 82 | .HasMaxLength(100); 83 | 84 | // ModifiedOn 85 | builder.Property(p => p.ModifiedAt) 86 | .IsRequired(false); 87 | 88 | // ModifiedByUserId 89 | builder.Property(p => p.ModifiedByUserId) 90 | .IsRequired(false) 91 | .HasMaxLength(100); 92 | 93 | builder.ToTable("application_users"); 94 | } 95 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/ApplicationUserLoginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.Configurations; 6 | 7 | public sealed class ApplicationUserLoginConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Composite primary key consisting of the LoginProvider and the key to use 12 | // with that provider 13 | builder.HasKey(l => new { l.LoginProvider, l.ProviderKey }); 14 | 15 | // Limit the size of the composite key columns due to common DB restrictions 16 | builder.Property(l => l.LoginProvider).HasMaxLength(128); 17 | builder.Property(l => l.ProviderKey).HasMaxLength(128); 18 | 19 | // Maps to the AspNetUserLogins table 20 | builder.ToTable("application_user_logins"); 21 | } 22 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/ApplicationUserRoleConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.Configurations; 6 | 7 | public sealed class ApplicationUserRoleConfiguration : IEntityTypeConfiguration 8 | 9 | { 10 | public void Configure(EntityTypeBuilder builder) 11 | 12 | { 13 | // Primary key 14 | builder.HasKey(r => new { r.UserId, r.RoleId }); 15 | 16 | // Maps to the AspNetUserRoles table 17 | builder.ToTable("application_user_roles"); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/ApplicationUserTokenConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.Configurations; 6 | 7 | public sealed class ApplicationUserTokenConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Composite primary key consisting of the UserId, LoginProvider and Name 12 | builder.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); 13 | 14 | // Limit the size of the composite key columns due to common DB restrictions 15 | builder.Property(t => t.LoginProvider).HasMaxLength(191); 16 | builder.Property(t => t.Name).HasMaxLength(191); 17 | 18 | // Maps to the AspNetUserTokens table 19 | builder.ToTable("application_user_tokens"); 20 | } 21 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/CategoryConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 6 | 7 | public sealed class CategoryConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Id 12 | builder.HasKey(x => x.Id); 13 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 14 | 15 | // Name 16 | builder.Property(x => x.Name) 17 | .IsRequired() 18 | .HasMaxLength(100); 19 | 20 | // Description 21 | builder.Property(x => x.Description) 22 | .IsRequired() 23 | .HasMaxLength(500); 24 | 25 | // Common Properties 26 | 27 | // CreatedAt 28 | builder.Property(p => p.CreatedAt) 29 | .IsRequired(); 30 | 31 | // CreatedByUserId 32 | builder.Property(p => p.CreatedByUserId) 33 | .IsRequired(false) 34 | .HasMaxLength(100); 35 | 36 | // ModifiedAt 37 | builder.Property(p => p.ModifiedAt) 38 | .IsRequired(false); 39 | 40 | // ModifiedByUserId 41 | builder.Property(p => p.ModifiedByUserId) 42 | .IsRequired(false) 43 | .HasMaxLength(100); 44 | 45 | // Table Name 46 | builder.ToTable("categories"); 47 | } 48 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/PlaceholderConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 6 | 7 | public sealed class PlaceholderConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Id 12 | builder.HasKey(x => x.Id); 13 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 14 | 15 | // Name 16 | builder.Property(x => x.Name) 17 | .IsRequired() 18 | .HasMaxLength(200); 19 | 20 | // CreatedAt 21 | builder.Property(p => p.CreatedAt) 22 | .IsRequired(); 23 | 24 | // CreatedByUserId 25 | builder.Property(p => p.CreatedByUserId) 26 | .IsRequired(false) 27 | .HasMaxLength(100); 28 | 29 | // ModifiedAt 30 | builder.Property(p => p.ModifiedAt) 31 | .IsRequired(false); 32 | 33 | // ModifiedByUserId 34 | builder.Property(p => p.ModifiedByUserId) 35 | .IsRequired(false) 36 | .HasMaxLength(100); 37 | 38 | // Table Name 39 | builder.ToTable("placeholders"); 40 | } 41 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/PromptCategoryConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 6 | 7 | public sealed class PromptCategoryConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Id 12 | builder.HasKey(x => x.Id); 13 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 14 | 15 | // Prompt Relationship 16 | builder.HasOne(x => x.Prompt) 17 | .WithMany(p => p.PromptCategories) 18 | .HasForeignKey(x => x.PromptId) 19 | .IsRequired(); 20 | 21 | // Category Relationship 22 | builder.HasOne(x => x.Category) 23 | .WithMany(c => c.PromptCategories) 24 | .HasForeignKey(x => x.CategoryId) 25 | .IsRequired(); 26 | 27 | // Unique Constraint for Prompt and Category Combination 28 | builder.HasIndex(x => new { x.PromptId, x.CategoryId }) 29 | .IsUnique(); 30 | 31 | // CreatedAt 32 | builder.Property(p => p.CreatedAt) 33 | .IsRequired(); 34 | 35 | // CreatedByUserId 36 | builder.Property(p => p.CreatedByUserId) 37 | .IsRequired(false) 38 | .HasMaxLength(100); 39 | 40 | // ModifiedAt 41 | builder.Property(p => p.ModifiedAt) 42 | .IsRequired(false); 43 | 44 | // ModifiedByUserId 45 | builder.Property(p => p.ModifiedByUserId) 46 | .IsRequired(false) 47 | .HasMaxLength(100); 48 | 49 | // Table Name 50 | builder.ToTable("prompt_categories"); 51 | } 52 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/PromptCommentConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 6 | 7 | public sealed class UserPromptCommentConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Id 12 | builder.HasKey(x => x.Id); 13 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 14 | 15 | // Level 16 | builder.Property(x => x.Level) 17 | .IsRequired(); 18 | 19 | // Content 20 | builder.Property(x => x.Content) 21 | .IsRequired() 22 | .HasMaxLength(1000); 23 | 24 | 25 | // User Relationship 26 | builder.HasOne(x => x.User) 27 | .WithMany(u => u.PromptComments) 28 | .HasForeignKey(x => x.UserId); 29 | 30 | // Parent Comment Relationship 31 | builder.HasOne(x => x.ParentComment) 32 | .WithMany(pc => pc.ChildComments) 33 | .HasForeignKey(x => x.ParentCommentId) 34 | .IsRequired(false); 35 | 36 | // CreatedAt 37 | builder.Property(p => p.CreatedAt) 38 | .IsRequired(); 39 | 40 | // CreatedByUserId 41 | builder.Property(p => p.CreatedByUserId) 42 | .IsRequired(false) 43 | .HasMaxLength(100); 44 | 45 | // ModifiedAt 46 | builder.Property(p => p.ModifiedAt) 47 | .IsRequired(false); 48 | 49 | // ModifiedByUserId 50 | builder.Property(p => p.ModifiedByUserId) 51 | .IsRequired(false) 52 | .HasMaxLength(100); 53 | 54 | // Table Name 55 | builder.ToTable("user_prompt_comments"); 56 | } 57 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/PromptConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using AkilliPrompt.Domain.Identity; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 5 | 6 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 7 | 8 | public sealed class PromptConfiguration : IEntityTypeConfiguration 9 | { 10 | public void Configure(EntityTypeBuilder builder) 11 | { 12 | // Id 13 | builder.HasKey(x => x.Id); 14 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 15 | 16 | // Title 17 | builder.Property(x => x.Title) 18 | .IsRequired() 19 | .HasMaxLength(200); 20 | 21 | // Description 22 | builder.Property(x => x.Description) 23 | .IsRequired() 24 | .HasMaxLength(5000); 25 | 26 | // Content 27 | builder.Property(x => x.Content) 28 | .IsRequired(); 29 | 30 | // ImageUrl 31 | builder.Property(x => x.ImageUrl) 32 | .HasMaxLength(1024) 33 | .IsRequired(false); 34 | 35 | // IsActive 36 | builder.Property(x => x.IsActive) 37 | .IsRequired() 38 | .HasDefaultValue(false); 39 | 40 | // LikeCount 41 | builder.Property(x => x.LikeCount) 42 | .IsRequired() 43 | .HasDefaultValue(0); 44 | 45 | builder.HasIndex(x => x.LikeCount) 46 | .IsDescending() 47 | .HasDatabaseName("IX_Prompts_LikeCount_Desc"); 48 | 49 | // CreatorId 50 | builder.HasOne(x => x.Creator) 51 | .WithMany(x => x.CreatedPrompts) 52 | .HasForeignKey(x => x.CreatorId); 53 | 54 | // UserFavoritePrompts Relationship 55 | builder.HasMany(x => x.UserFavoritePrompts) 56 | .WithOne(ufp => ufp.Prompt) 57 | .HasForeignKey(ufp => ufp.PromptId); 58 | 59 | // UserLikePrompts Relationship 60 | builder.HasMany(x => x.UserLikePrompts) 61 | .WithOne(ulp => ulp.Prompt) 62 | .HasForeignKey(ulp => ulp.PromptId); 63 | 64 | // Placeholders Relationship 65 | builder.HasMany(x => x.Placeholders) 66 | .WithOne(p => p.Prompt) 67 | .HasForeignKey(p => p.PromptId); 68 | 69 | // PromptComments Relationship 70 | builder.HasMany(x => x.PromptComments) 71 | .WithOne(y => y.Prompt) 72 | .HasForeignKey(y => y.PromptId); 73 | 74 | 75 | // CreatedAt 76 | builder.Property(p => p.CreatedAt) 77 | .IsRequired(); 78 | 79 | // CreatedByUserId 80 | builder.Property(p => p.CreatedByUserId) 81 | .IsRequired(false); 82 | //.HasMaxLength(150); 83 | 84 | // ModifiedAt 85 | builder.Property(p => p.ModifiedAt) 86 | .IsRequired(false); 87 | 88 | // ModifiedByUserId 89 | builder.Property(p => p.ModifiedByUserId) 90 | .IsRequired(false); 91 | //.HasMaxLength(150); 92 | 93 | // Table Name 94 | builder.ToTable("prompts"); 95 | } 96 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/UserFavoritePromptConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 6 | 7 | public sealed class UserFavoritePromptConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Id 12 | builder.HasKey(x => x.Id); 13 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 14 | 15 | 16 | // User Relationship 17 | builder.HasOne(x => x.User) 18 | .WithMany(u => u.UserFavoritePrompts) 19 | .HasForeignKey(x => x.UserId); 20 | 21 | // Prompt Relationship 22 | builder.HasOne(x => x.Prompt) 23 | .WithMany(p => p.UserFavoritePrompts) 24 | .HasForeignKey(x => x.PromptId); 25 | 26 | // Unique Constraint for User and Prompt Combination 27 | builder.HasIndex(x => new { x.UserId, x.PromptId }) 28 | .IsUnique(); //BUNU KONTROL ETMELİYİZ 29 | 30 | 31 | // CreatedAt 32 | builder.Property(p => p.CreatedAt) 33 | .IsRequired(); 34 | 35 | // CreatedByUserId 36 | builder.Property(p => p.CreatedByUserId) 37 | .IsRequired(false) 38 | .HasMaxLength(100); 39 | 40 | // ModifiedAt 41 | builder.Property(p => p.ModifiedAt) 42 | .IsRequired(false); 43 | 44 | // ModifiedByUserId 45 | builder.Property(p => p.ModifiedByUserId) 46 | .IsRequired(false) 47 | .HasMaxLength(100); 48 | 49 | // Table Name 50 | builder.ToTable("user_favorite_prompts"); 51 | } 52 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/UserLikePromptConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 6 | 7 | public sealed class UserLikePromptConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Id 12 | builder.HasKey(x => x.Id); 13 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 14 | 15 | // User Relationship 16 | builder.HasOne(x => x.User) 17 | .WithMany(u => u.UserLikePrompts) 18 | .HasForeignKey(x => x.UserId); 19 | 20 | // Prompt Relationship 21 | builder.HasOne(x => x.Prompt) 22 | .WithMany(p => p.UserLikePrompts) 23 | .HasForeignKey(x => x.PromptId); 24 | 25 | // Unique Constraint for User and Prompt Combination 26 | builder.HasIndex(x => new { x.UserId, x.PromptId }) 27 | .IsUnique(); 28 | 29 | 30 | // CreatedAt 31 | builder.Property(p => p.CreatedAt) 32 | .IsRequired(); 33 | 34 | // CreatedByUserId 35 | builder.Property(p => p.CreatedByUserId) 36 | .IsRequired(false) 37 | .HasMaxLength(100); 38 | 39 | // ModifiedAt 40 | builder.Property(p => p.ModifiedAt) 41 | .IsRequired(false); 42 | 43 | // ModifiedByUserId 44 | builder.Property(p => p.ModifiedByUserId) 45 | .IsRequired(false) 46 | .HasMaxLength(100); 47 | 48 | // Table Name 49 | builder.ToTable("user_like_prompts"); 50 | } 51 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Configurations/UserSocialMediaAccountConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Configurations; 6 | 7 | public sealed class UserSocialMediaAccountConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | // Id 12 | builder.HasKey(x => x.Id); 13 | builder.Property(x => x.Id).ValueGeneratedOnAdd(); 14 | 15 | // SocialMediaType 16 | builder.Property(x => x.SocialMediaType) 17 | .HasColumnType("smallint") 18 | .HasConversion() 19 | .IsRequired(); 20 | 21 | // Url 22 | builder.Property(x => x.Url) 23 | .IsRequired() 24 | .HasMaxLength(1024); 25 | 26 | 27 | // User Relationship 28 | builder.HasOne(x => x.User) 29 | .WithMany(u => u.UserSocialMediaAccounts) 30 | .HasForeignKey(x => x.UserId); 31 | 32 | // CreatedAt 33 | builder.Property(p => p.CreatedAt) 34 | .IsRequired(); 35 | 36 | // CreatedByUserId 37 | builder.Property(p => p.CreatedByUserId) 38 | .IsRequired(false) 39 | .HasMaxLength(100); 40 | 41 | // ModifiedAt 42 | builder.Property(p => p.ModifiedAt) 43 | .IsRequired(false); 44 | 45 | // ModifiedByUserId 46 | builder.Property(p => p.ModifiedByUserId) 47 | .IsRequired(false) 48 | .HasMaxLength(100); 49 | 50 | // Table Name 51 | builder.ToTable("user_social_media_accounts"); 52 | } 53 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Contexts/ApplicationDbContext.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using AkilliPrompt.Domain.Identity; 3 | using AkilliPrompt.Persistence.EntityFramework.Extensions; 4 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace AkilliPrompt.Persistence.EntityFramework.Contexts; 8 | 9 | public sealed class ApplicationDbContext : IdentityDbContext 10 | { 11 | public ApplicationDbContext(DbContextOptions options) 12 | : base(options) 13 | { 14 | 15 | } 16 | 17 | public DbSet Placeholders { get; set; } 18 | public DbSet Categories { get; set; } 19 | public DbSet Prompts { get; set; } 20 | public DbSet PromptCategories { get; set; } 21 | public DbSet UserSocialMediaAccounts { get; set; } 22 | public DbSet PromptComments { get; set; } 23 | public DbSet UserFavoritePrompts { get; set; } 24 | public DbSet UserLikePrompts { get; set; } 25 | public DbSet RefreshTokens { get; set; } 26 | 27 | protected override void OnModelCreating(ModelBuilder builder) 28 | { 29 | builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); 30 | 31 | builder.ToSnakeCaseNames(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Contexts/ApplicationDbContextFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Design; 4 | using Microsoft.EntityFrameworkCore.Diagnostics; 5 | using Microsoft.Extensions.Configuration; 6 | 7 | namespace AkilliPrompt.Persistence.EntityFramework.Contexts; 8 | 9 | public sealed class ApplicationDbContextFactory : IDesignTimeDbContextFactory 10 | { 11 | public ApplicationDbContext CreateDbContext(string[] args) 12 | { 13 | var configuration = new ConfigurationBuilder() 14 | .SetBasePath(Directory.GetCurrentDirectory()) 15 | .AddJsonFile("appsettings.json") 16 | .AddJsonFile("appsettings.Development.json", optional: true) 17 | .Build(); 18 | 19 | var connectionString = configuration.GetConnectionString("DefaultConnection"); 20 | 21 | var optionsBuilder = new DbContextOptionsBuilder(); 22 | 23 | optionsBuilder.UseNpgsql(connectionString, b => b.MigrationsHistoryTable("__ef_migrations_history")).ConfigureWarnings(warnings => warnings.Throw(RelationalEventId.PendingModelChangesWarning)); 24 | 25 | return new ApplicationDbContext(optionsBuilder.Options); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Extensions/ConvertionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.Persistence.EntityFramework.Extensions; 6 | 7 | public static class ConvertionExtensions 8 | { 9 | private static CultureInfo _culture; 10 | 11 | public static void ToSnakeCaseNames(this ModelBuilder modelBuilder) 12 | { 13 | _culture = CultureInfo.InvariantCulture; 14 | 15 | SetNames(modelBuilder, NamingConvention.SnakeCase); 16 | } 17 | 18 | public static void ToLowerCaseNames(this ModelBuilder modelBuilder) 19 | { 20 | _culture = CultureInfo.InvariantCulture; 21 | 22 | SetNames(modelBuilder, NamingConvention.LowerCase); 23 | } 24 | 25 | private static string? NameRewriter(this string name, NamingConvention naming) 26 | { 27 | if (string.IsNullOrEmpty(name)) return name; 28 | 29 | return naming == NamingConvention.SnakeCase 30 | ? SnakeCaseNameRewriter(name) 31 | : LowerCaseNameRewriter(name); 32 | } 33 | 34 | private enum NamingConvention 35 | { 36 | SnakeCase, 37 | LowerCase, 38 | } 39 | 40 | private static void SetNames(ModelBuilder modelBuilder, NamingConvention naming) 41 | { 42 | _culture = CultureInfo.InvariantCulture; 43 | 44 | foreach (var entity in modelBuilder.Model.GetEntityTypes()) 45 | { 46 | entity.SetViewName(entity.GetViewName()?.NameRewriter(naming)); 47 | entity.SetSchema(entity.GetSchema()?.NameRewriter(naming)); 48 | entity.SetTableName(entity.GetTableName()?.NameRewriter(naming)); 49 | 50 | foreach (var property in entity!.GetProperties()) 51 | { 52 | property.SetColumnName(property.GetColumnName()?.NameRewriter(naming)); 53 | } 54 | 55 | foreach (var key in entity.GetKeys()) 56 | { 57 | key.SetName(key.GetName()?.NameRewriter(naming)); 58 | } 59 | 60 | foreach (var key in entity.GetForeignKeys()) 61 | { 62 | key.SetConstraintName(key.GetConstraintName()?.NameRewriter(naming)); 63 | } 64 | 65 | foreach (var index in entity.GetIndexes()) 66 | { 67 | index.SetDatabaseName(index.GetDatabaseName()?.NameRewriter(naming)); 68 | } 69 | } 70 | } 71 | 72 | private static string? LowerCaseNameRewriter(string name) 73 | => name.ToLower(_culture); 74 | 75 | // https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs 76 | private static string SnakeCaseNameRewriter(string name) 77 | { 78 | var builder = new StringBuilder(name.Length + Math.Min(2, name.Length / 5)); 79 | var previousCategory = default(UnicodeCategory?); 80 | 81 | for (var currentIndex = 0; currentIndex < name.Length; currentIndex++) 82 | { 83 | var currentChar = name[currentIndex]; 84 | if (currentChar == '_') 85 | { 86 | builder.Append('_'); 87 | previousCategory = null; 88 | continue; 89 | } 90 | 91 | var currentCategory = char.GetUnicodeCategory(currentChar); 92 | switch (currentCategory) 93 | { 94 | case UnicodeCategory.UppercaseLetter: 95 | case UnicodeCategory.TitlecaseLetter: 96 | if (previousCategory == UnicodeCategory.SpaceSeparator || 97 | previousCategory == UnicodeCategory.LowercaseLetter || 98 | previousCategory != UnicodeCategory.DecimalDigitNumber && 99 | previousCategory != null && 100 | currentIndex > 0 && 101 | currentIndex + 1 < name.Length && 102 | char.IsLower(name[currentIndex + 1])) 103 | { 104 | builder.Append('_'); 105 | } 106 | 107 | currentChar = char.ToLower(currentChar, _culture); 108 | break; 109 | 110 | case UnicodeCategory.LowercaseLetter: 111 | case UnicodeCategory.DecimalDigitNumber: 112 | if (previousCategory == UnicodeCategory.SpaceSeparator) 113 | { 114 | builder.Append('_'); 115 | } 116 | break; 117 | 118 | default: 119 | if (previousCategory != null) 120 | { 121 | previousCategory = UnicodeCategory.SpaceSeparator; 122 | } 123 | continue; 124 | } 125 | 126 | builder.Append(currentChar); 127 | previousCategory = currentCategory; 128 | } 129 | 130 | return builder.ToString().ToLower(_culture); 131 | } 132 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Interceptors/EntityInterceptor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AkilliPrompt.Domain.Common; 3 | using AkilliPrompt.Persistence.Services; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.ChangeTracking; 6 | using Microsoft.EntityFrameworkCore.Diagnostics; 7 | 8 | namespace AkilliPrompt.Persistence.EntityFramework.Interceptors 9 | { 10 | public sealed class EntityInterceptor : SaveChangesInterceptor 11 | { 12 | private readonly ICurrentUserService _currentUserService; 13 | 14 | public EntityInterceptor(ICurrentUserService currentUserService) 15 | { 16 | _currentUserService = currentUserService; 17 | } 18 | 19 | public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) 20 | { 21 | UpdateEntities(eventData.Context); 22 | 23 | return base.SavingChanges(eventData, result); 24 | } 25 | 26 | public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) 27 | { 28 | UpdateEntities(eventData.Context); 29 | 30 | return base.SavingChangesAsync(eventData, result, cancellationToken); 31 | } 32 | 33 | private void UpdateEntities(DbContext? context) 34 | { 35 | if (context is null) 36 | return; 37 | 38 | foreach (var entry in context.ChangeTracker.Entries()) 39 | { 40 | if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities()) 41 | { 42 | var utcNow = DateTimeOffset.UtcNow; 43 | 44 | if (entry.State == EntityState.Added) 45 | { 46 | entry.Entity.CreatedByUserId = _currentUserService.UserId == Guid.Empty ? null : _currentUserService.UserId.ToString(); 47 | entry.Entity.CreatedAt = utcNow; 48 | } 49 | 50 | if (entry.State == EntityState.Modified) 51 | { 52 | entry.Entity.ModifiedByUserId = _currentUserService.UserId == Guid.Empty ? null : _currentUserService.UserId.ToString(); 53 | entry.Entity.ModifiedAt = utcNow; 54 | } 55 | } 56 | } 57 | } 58 | 59 | } 60 | public static class Extensions 61 | { 62 | public static bool HasChangedOwnedEntities(this EntityEntry entry) => 63 | entry.References.Any(r => 64 | r.TargetEntry != null && 65 | r.TargetEntry.Metadata.IsOwned() && 66 | (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified)); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/EntityFramework/Seeders/ApplicationRoleSeeder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AkilliPrompt.Domain.Identity; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 5 | 6 | namespace AkilliPrompt.Persistence.EntityFramework.Seeders; 7 | 8 | public sealed class ApplicationRoleSeeder : IEntityTypeConfiguration 9 | { 10 | public void Configure(EntityTypeBuilder builder) 11 | { 12 | var adminRole = new ApplicationRole 13 | { 14 | Id = new Guid("019358eb-f6cb-78c6-b59c-848777da66af"), 15 | Name = "Admin", 16 | NormalizedName = "ADMIN", 17 | ConcurrencyStamp = "019358ec-42e0-70ba-8049-655ecc8e2d2e", 18 | }; 19 | 20 | var userRole = new ApplicationRole 21 | { 22 | Id = new Guid("019358ec-9d53-7785-a270-e22e10677a63"), 23 | Name = "User", 24 | NormalizedName = "USER", 25 | ConcurrencyStamp = "019358ec-aedc-742c-b677-a6b6bd8ef3bb", 26 | }; 27 | 28 | builder.HasData(adminRole, userRole); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/AkilliPrompt.Persistence/Services/ICurrentUserService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace AkilliPrompt.Persistence.Services; 4 | 5 | public interface ICurrentUserService 6 | { 7 | Guid UserId { get; } 8 | string IpAddress { get; } 9 | } 10 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/AkilliPrompt.WebApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | f01eff83-4ba6-461e-bed7-5c82a97d7d34 8 | Linux 9 | ..\.. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | runtime; build; native; contentfiles; analyzers; buildtransitive 27 | all 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Attributes/CacheKeyPartAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.Attributes; 2 | 3 | [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] 4 | public sealed class CacheKeyPartAttribute : Attribute 5 | { 6 | // Optionally, add properties to control behavior, such as encoding 7 | public bool Encode { get; set; } = true; 8 | 9 | public string Prefix { get; set; } = string.Empty; 10 | } 11 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Attributes/CacheOptionsAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.Attributes; 2 | 3 | [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] 4 | public sealed class CacheOptionsAttribute : Attribute 5 | { 6 | public TimeSpan? AbsoluteExpirationRelativeToNow { get; } 7 | public TimeSpan? SlidingExpiration { get; } 8 | 9 | public CacheOptionsAttribute(double absoluteExpirationMinutes = 30, double slidingExpirationMinutes = 10) 10 | { 11 | AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(absoluteExpirationMinutes); 12 | SlidingExpiration = TimeSpan.FromMinutes(slidingExpirationMinutes); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Behaviors/CachingBehavior.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text.Json; 3 | using AkilliPrompt.Domain.Common; 4 | using AkilliPrompt.WebApi.Attributes; 5 | using AkilliPrompt.WebApi.Services; 6 | using MediatR; 7 | using Microsoft.Extensions.Caching.Distributed; 8 | using StackExchange.Redis; 9 | 10 | namespace AkilliPrompt.WebApi.Behaviors; 11 | 12 | public sealed class CachingBehavior : IPipelineBehavior 13 | where TRequest : ICacheable 14 | { 15 | private readonly IDistributedCache _cache; 16 | private readonly CacheKeyFactory _cacheKeyFactory; 17 | private readonly ILogger> _logger; 18 | private readonly IDatabase _redisDb; 19 | private static readonly DistributedCacheEntryOptions _defaultCacheOptions = new() 20 | { 21 | AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(60), 22 | SlidingExpiration = TimeSpan.FromMinutes(15) 23 | }; 24 | 25 | public CachingBehavior( 26 | IDistributedCache cache, 27 | CacheKeyFactory cacheKeyFactory, 28 | ILogger> logger, 29 | IConnectionMultiplexer redis) 30 | { 31 | _cache = cache; 32 | _cacheKeyFactory = cacheKeyFactory; 33 | _logger = logger; 34 | _redisDb = redis.GetDatabase(); 35 | } 36 | 37 | public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) 38 | { 39 | var cacheKey = _cacheKeyFactory.CreateCacheKey(request); 40 | 41 | var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); 42 | 43 | if (!string.IsNullOrEmpty(cachedData)) 44 | { 45 | _logger.LogInformation($"[Cache Hit] Key: {cacheKey}"); 46 | 47 | return JsonSerializer.Deserialize(cachedData); 48 | } 49 | 50 | _logger.LogInformation($"[Cache Miss] Key: {cacheKey}. Fetching from database."); 51 | 52 | var response = await next(); 53 | 54 | var serializedResponse = JsonSerializer.Serialize(response); 55 | 56 | // Retrieve custom cache options from attribute 57 | var cacheOptions = GetCacheOptionsFromAttribute() ?? _defaultCacheOptions; 58 | 59 | await _cache.SetStringAsync(cacheKey, serializedResponse, cacheOptions, cancellationToken); 60 | 61 | // Add cache key to group 62 | if (!string.IsNullOrEmpty(request.CacheGroup)) 63 | { 64 | var groupSetKey = $"Group:{request.CacheGroup}"; 65 | 66 | await _redisDb.SetAddAsync(groupSetKey, cacheKey); 67 | } 68 | 69 | return response; 70 | } 71 | 72 | private DistributedCacheEntryOptions? GetCacheOptionsFromAttribute() 73 | { 74 | var attribute = typeof(TRequest).GetCustomAttribute(); 75 | 76 | if (attribute is null) 77 | return null; 78 | 79 | var options = new DistributedCacheEntryOptions(); 80 | 81 | if (attribute.AbsoluteExpirationRelativeToNow.HasValue) 82 | options.AbsoluteExpirationRelativeToNow = attribute.AbsoluteExpirationRelativeToNow; 83 | 84 | if (attribute.SlidingExpiration.HasValue) 85 | options.SlidingExpiration = attribute.SlidingExpiration; 86 | 87 | return options; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Behaviors/ValidationBehavior.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentValidation; 3 | using MediatR; 4 | 5 | namespace AkilliPrompt.WebApi.Behaviors; 6 | 7 | public sealed class ValidationBehavior : IPipelineBehavior 8 | where TRequest : IRequest 9 | { 10 | private readonly IEnumerable> _validators; 11 | 12 | public ValidationBehavior(IEnumerable> validators) 13 | { 14 | _validators = validators; 15 | } 16 | 17 | public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) 18 | { 19 | if (_validators.Any()) 20 | { 21 | var context = new ValidationContext(request); 22 | 23 | var validationResults = await Task.WhenAll( 24 | _validators.Select(v => 25 | v.ValidateAsync(context, cancellationToken))); 26 | 27 | var failures = validationResults 28 | .Where(r => r.Errors.Any()) 29 | .SelectMany(r => r.Errors) 30 | .ToList(); 31 | 32 | if (failures.Any()) 33 | throw new ValidationException(failures); 34 | } 35 | 36 | return await next(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Common/FluentValidation/EntityExistsValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.WebApi.Services; 3 | using FluentValidation; 4 | 5 | namespace AkilliPrompt.WebApi.Common.FluentValidation; 6 | 7 | /// 8 | /// Base validator to check existence of an entity by ID. 9 | /// 10 | /// Type of the entity. 11 | /// Type of the command containing the ID. 12 | public abstract class EntityExistsValidator : AbstractValidator 13 | where TEntity : EntityBase 14 | { 15 | private readonly IExistenceService _existenceService; 16 | 17 | public EntityExistsValidator(IExistenceService existenceService) 18 | { 19 | _existenceService = existenceService; 20 | 21 | RuleFor(e => GetEntityId(e)) 22 | .NotEmpty() 23 | .WithMessage($"Lütfen geçerli bir {typeof(TEntity).Name} kimliği sağlayın.") 24 | .MustAsync(EntityExists) 25 | .WithMessage($"Belirtilen {typeof(TEntity).Name} mevcut değil."); 26 | } 27 | 28 | /// 29 | /// Extracts the entity ID from the command. 30 | /// 31 | /// The command containing the ID. 32 | /// The entity ID. 33 | protected abstract Guid GetEntityId(TCommand command); 34 | 35 | /// 36 | /// Checks if the entity exists using the ExistenceService. 37 | /// 38 | /// Entity ID. 39 | /// Cancellation token. 40 | /// True if exists; otherwise, false. 41 | private async Task EntityExists(Guid id, CancellationToken cancellationToken) 42 | { 43 | return await _existenceService.ExistsAsync(id, cancellationToken); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Configuration/SwaggerConfiguration.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Options; 2 | using Asp.Versioning; 3 | using Asp.Versioning.ApiExplorer; 4 | using Microsoft.OpenApi.Models; 5 | using Swashbuckle.AspNetCore.Filters; 6 | 7 | namespace AkilliPrompt.WebApi.Configuration; 8 | 9 | public static class SwaggerConfiguration 10 | { 11 | public static IServiceCollection AddSwaggerWithVersion(this IServiceCollection services) 12 | { 13 | services.AddSwaggerGen(setupAction => 14 | { 15 | setupAction.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme 16 | { 17 | Type = SecuritySchemeType.Http, 18 | Scheme = "bearer", 19 | BearerFormat = "JWT", 20 | Description = $"Input your Bearer token in this format - Bearer token to access this API", 21 | }); 22 | 23 | setupAction.AddSecurityRequirement(new OpenApiSecurityRequirement 24 | { 25 | { 26 | new OpenApiSecurityScheme 27 | { 28 | Reference = new OpenApiReference 29 | { 30 | Type = ReferenceType.SecurityScheme, 31 | Id = "Bearer", 32 | }, 33 | }, new List() 34 | }, 35 | }); 36 | }); 37 | 38 | services. 39 | AddApiVersioning(options => 40 | { 41 | options.ReportApiVersions = true; 42 | options.DefaultApiVersion = new ApiVersion(1, 0); 43 | options.AssumeDefaultVersionWhenUnspecified = true; 44 | options.ApiVersionReader = new UrlSegmentApiVersionReader(); //ApiVersionReader.Combine(new QueryStringApiVersionReader(), new UrlSegmentApiVersionReader(), new HeaderApiVersionReader("X-Api-Version"), new MediaTypeApiVersionReader("X-Api-Version")); 45 | }) 46 | .AddApiExplorer(options => 47 | { 48 | options.GroupNameFormat = "'v'VVV"; 49 | options.SubstituteApiVersionInUrl = true; 50 | }); 51 | 52 | services.ConfigureOptions(); 53 | 54 | services.AddSwaggerExamplesFromAssemblyOf(); 55 | 56 | return services; 57 | } 58 | 59 | 60 | public static IApplicationBuilder UseSwaggerWithVersion(this IApplicationBuilder app) 61 | { 62 | IApiVersionDescriptionProvider apiVersionDescriptionProvider = app.ApplicationServices.GetRequiredService(); 63 | 64 | app.UseSwagger(); 65 | app.UseSwaggerUI(options => 66 | { 67 | foreach (ApiVersionDescription description in apiVersionDescriptionProvider.ApiVersionDescriptions) 68 | { 69 | options.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", $"AkilliPrompt API {description.GroupName.ToUpperInvariant()}"); 70 | } 71 | }); 72 | 73 | return app; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text; 3 | using AkilliPrompt.Domain.Common; 4 | using AkilliPrompt.Domain.Identity; 5 | using AkilliPrompt.Domain.Settings; 6 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 7 | using AkilliPrompt.Persistence.Services; 8 | using AkilliPrompt.WebApi.Behaviors; 9 | using AkilliPrompt.WebApi.Configuration; 10 | using AkilliPrompt.WebApi.Services; 11 | using AkilliPrompt.WebApi.V1.Auth.Commands.GoogleLogin; 12 | using FluentValidation; 13 | using IAPriceTrackerApp.WebApi.Services; 14 | using MediatR; 15 | using Microsoft.AspNetCore.Authentication.JwtBearer; 16 | using Microsoft.AspNetCore.Identity; 17 | using Microsoft.IdentityModel.Tokens; 18 | using StackExchange.Redis; 19 | 20 | namespace AkilliPrompt.WebApi; 21 | 22 | public static class DependencyInjection 23 | { 24 | public static IServiceCollection AddWebApi(this IServiceCollection services, IConfiguration configuration) 25 | { 26 | services.AddCors(options => 27 | { 28 | options.AddPolicy("AllowAll", 29 | builder => builder 30 | .AllowAnyMethod() 31 | .AllowCredentials() 32 | .SetIsOriginAllowed((host) => true) 33 | .AllowAnyHeader()); 34 | }); 35 | 36 | services.AddSwaggerWithVersion(); 37 | services.AddEndpointsApiExplorer(); 38 | 39 | services.AddMemoryCache(); 40 | 41 | services.AddProblemDetails(); 42 | services.AddApiVersioning( 43 | options => 44 | { 45 | options.ReportApiVersions = true; 46 | }); 47 | 48 | services.AddHttpContextAccessor(); 49 | 50 | services.AddScoped(); 51 | 52 | services.Configure( 53 | configuration.GetSection(nameof(CloudflareR2Settings))); 54 | 55 | services.Configure( 56 | configuration.GetSection(nameof(JwtSettings))); 57 | 58 | services.Configure( 59 | configuration.GetSection(nameof(GoogleAuthSettings))); 60 | 61 | 62 | // Scoped Services 63 | services.AddScoped(); 64 | 65 | services.AddIdentity(options => 66 | { 67 | options.User.RequireUniqueEmail = true; 68 | 69 | options.Password.RequireNonAlphanumeric = false; 70 | options.Password.RequireUppercase = false; 71 | options.Password.RequireLowercase = false; 72 | options.Password.RequireDigit = false; 73 | options.Password.RequiredUniqueChars = 0; 74 | options.Password.RequiredLength = 6; 75 | }) 76 | .AddEntityFrameworkStores() 77 | .AddDefaultTokenProviders(); 78 | 79 | services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); 80 | 81 | services.AddMediatR(config => 82 | { 83 | config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); 84 | 85 | config.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); 86 | 87 | config.AddBehavior(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); 88 | 89 | }); 90 | 91 | // Configure Dragonfly as the caching provider 92 | services.AddStackExchangeRedisCache(options => 93 | { 94 | options.Configuration = configuration.GetConnectionString("Dragonfly"); 95 | options.InstanceName = "AkilliPrompt_"; // Optional: Use a specific instance name 96 | // Add any Dragonfly-specific configurations here 97 | // For example, if Dragonfly supports specific features or optimizations, configure them here 98 | }); 99 | 100 | services.AddScoped(); 101 | 102 | services.AddSingleton(); 103 | 104 | services.AddScoped(typeof(IExistenceService<>), typeof(ExistenceManager<>)); 105 | 106 | // Register Redis connection for advanced operations 107 | services.AddSingleton(sp => 108 | ConnectionMultiplexer.Connect(configuration.GetConnectionString("Dragonfly"))); 109 | 110 | services.AddScoped(); 111 | 112 | services.AddAuthentication(options => 113 | { 114 | options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; 115 | options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; 116 | options.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme; 117 | options.DefaultSignOutScheme = JwtBearerDefaults.AuthenticationScheme; 118 | options.DefaultForbidScheme = JwtBearerDefaults.AuthenticationScheme; 119 | }) 120 | .AddJwtBearer(options => 121 | { 122 | var secretKey = configuration["JwtSettings:SecretKey"]; 123 | 124 | if (string.IsNullOrEmpty(secretKey)) 125 | throw new ArgumentNullException("JwtSettings:SecretKey is not set."); 126 | 127 | options.TokenValidationParameters = new TokenValidationParameters 128 | { 129 | ValidateIssuer = true, 130 | ValidateAudience = true, 131 | ValidateLifetime = true, 132 | ValidateIssuerSigningKey = true, 133 | ValidIssuer = configuration["JwtSettings:Issuer"], 134 | ValidAudience = configuration["JwtSettings:Audience"], 135 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), 136 | ClockSkew = TimeSpan.Zero 137 | }; 138 | }); 139 | 140 | return services; 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Dockerfile: -------------------------------------------------------------------------------- 1 | # 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. 2 | 3 | # This stage is used when running from VS in fast mode (Default for Debug configuration) 4 | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base 5 | USER $APP_UID 6 | WORKDIR /app 7 | EXPOSE 8080 8 | EXPOSE 8081 9 | 10 | 11 | # This stage is used to build the service project 12 | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build 13 | ARG BUILD_CONFIGURATION=Release 14 | WORKDIR /src 15 | COPY ["src/AkilliPrompt.WebApi/AkilliPrompt.WebApi.csproj", "src/AkilliPrompt.WebApi/"] 16 | RUN dotnet restore "./src/AkilliPrompt.WebApi/AkilliPrompt.WebApi.csproj" 17 | COPY . . 18 | WORKDIR "/src/src/AkilliPrompt.WebApi" 19 | RUN dotnet build "./AkilliPrompt.WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/build 20 | 21 | # This stage is used to publish the service project to be copied to the final stage 22 | FROM build AS publish 23 | ARG BUILD_CONFIGURATION=Release 24 | RUN dotnet publish "./AkilliPrompt.WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 25 | 26 | # This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) 27 | FROM base AS final 28 | WORKDIR /app 29 | COPY --from=publish /app/publish . 30 | ENTRYPOINT ["dotnet", "AkilliPrompt.WebApi.dll"] -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Extensions/ApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace AkilliPrompt.WebApi.Extensions; 5 | 6 | public static class ApplicationBuilderExtensions 7 | { 8 | public static IApplicationBuilder ApplyMigrations(this IApplicationBuilder app) 9 | { 10 | using var scope = app.ApplicationServices.CreateScope(); 11 | 12 | var dbContext = scope.ServiceProvider.GetRequiredService(); 13 | 14 | if (dbContext.Database.GetPendingMigrations().Any()) 15 | dbContext.Database.Migrate(); 16 | 17 | return app; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Filters/GlobalExceptionFilter.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.ValueObjects; 2 | using AkilliPrompt.WebApi.Helpers; 3 | using AkilliPrompt.WebApi.Models; 4 | using FluentValidation; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.Filters; 7 | 8 | namespace AkilliPrompt.WebApi.Filters; 9 | 10 | public class GlobalExceptionFilter : IExceptionFilter 11 | { 12 | private readonly ILogger _logger; 13 | public GlobalExceptionFilter(ILogger logger) 14 | { 15 | _logger = logger; 16 | } 17 | public void OnException(ExceptionContext context) 18 | { 19 | _logger.LogError(context.Exception, context.Exception.Message); 20 | 21 | context.ExceptionHandled = true; 22 | 23 | // Eğer hata bir doğrulama hatası ise 24 | if (context.Exception is ValidationException validationException) 25 | { 26 | 27 | var responseMessage = MessageHelper.GeneralValidationErrorMessage; 28 | 29 | var errors = validationException.Errors 30 | .GroupBy(e => e.PropertyName) 31 | .Select(g => new ValidationError(g.Key, g.Select(e => e.ErrorMessage))) 32 | .ToList(); 33 | 34 | // 400 - Bad Request 35 | context.Result = new BadRequestObjectResult(ResponseDto.Error(responseMessage, errors)) 36 | { 37 | StatusCode = StatusCodes.Status400BadRequest 38 | }; 39 | } 40 | else 41 | { 42 | // Diğer tüm hatalar için 500 - Internal Server Error 43 | context.Result = new ObjectResult(ResponseDto.Error(MessageHelper.GeneralErrorMessage)) 44 | { 45 | StatusCode = StatusCodes.Status500InternalServerError 46 | }; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Filters/SwaggerJsonIgnoreFilter.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text.Json.Serialization; 3 | using Microsoft.OpenApi.Models; 4 | using Swashbuckle.AspNetCore.SwaggerGen; 5 | 6 | namespace AkilliPrompt.WebApi.Filters; 7 | 8 | public sealed class SwaggerJsonIgnoreFilter : IOperationFilter 9 | { 10 | public void Apply(OpenApiOperation operation, OperationFilterContext context) 11 | { 12 | List ignoredProperties = context.MethodInfo.GetParameters() 13 | .SelectMany(p => p.ParameterType.GetProperties() 14 | .Where(prop => prop.GetCustomAttribute() != null)) 15 | .ToList(); 16 | 17 | if (!ignoredProperties.Any()) 18 | { 19 | return; 20 | } 21 | 22 | foreach (PropertyInfo property in ignoredProperties) 23 | { 24 | operation.Parameters = operation.Parameters 25 | .Where(p => !p.Name.Equals(property.Name, StringComparison.InvariantCulture)) 26 | .ToList(); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Helpers/CacheKeysHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace AkilliPrompt.WebApi.Helpers; 4 | 5 | public static class CacheKeysHelper 6 | { 7 | public static string GetAllCategoriesKey => "GetAllCategories"; 8 | public static string GetByIdCategoryKey(Guid id) => $"GetByIdCategory:{id}"; 9 | } 10 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Helpers/MessageHelper.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.Helpers; 2 | 3 | public static class MessageHelper 4 | { 5 | public static string GeneralValidationErrorMessage => "Bir veya daha fazla validasyon hatası oluştu."; 6 | public static string GeneralErrorMessage => "Beklenmedik bir sunucu hatası oluştu."; 7 | 8 | public static string GetApiSuccessCreatedMessage(string entityName) => $"{entityName} başarıyla oluşturuldu."; 9 | public static string GetApiSuccessUpdatedMessage(string entityName) => $"{entityName} başarıyla güncellendi."; 10 | public static string GetApiSuccessDeletedMessage(string entityName) => $"{entityName} başarıyla silindi."; 11 | } 12 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Interfaces/ICacheable.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.Domain.Common; 2 | 3 | public interface ICacheable 4 | { 5 | string CacheGroup { get; } // Optional: For grouping related cache keys 6 | // New properties for cache option 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Interfaces/IPaginated.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.Interfaces; 2 | 3 | public interface IPaginated 4 | { 5 | int PageNumber { get; } 6 | int PageSize { get; } 7 | } 8 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Models/PaginatedList.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace AkilliPrompt.WebApi.Models; 5 | 6 | public sealed record PaginatedList 7 | { 8 | public IReadOnlyCollection Items { get; } 9 | public int PageNumber { get; } 10 | public int TotalPages { get; } 11 | public int TotalCount { get; } 12 | public int PageSize { get; set; } 13 | 14 | public PaginatedList(IEnumerable items, int totalCount, int pageNumber, int pageSize) 15 | { 16 | Items = items.ToList().AsReadOnly(); 17 | 18 | TotalCount = totalCount; 19 | 20 | PageNumber = pageNumber; 21 | 22 | PageSize = pageSize; 23 | 24 | TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); 25 | } 26 | 27 | [JsonConstructor] 28 | public PaginatedList(IReadOnlyCollection items, int totalCount, int pageNumber, int pageSize) 29 | { 30 | PageNumber = pageNumber; 31 | TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); 32 | TotalCount = totalCount; 33 | Items = items; 34 | PageSize = pageSize; 35 | } 36 | 37 | public bool HasPreviousPage => PageNumber > 1; 38 | 39 | public bool HasNextPage => PageNumber < TotalPages; 40 | 41 | public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize) 42 | { 43 | var count = await source.CountAsync(); 44 | 45 | var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); 46 | 47 | return new PaginatedList(items, count, pageNumber, pageSize); 48 | } 49 | 50 | public static PaginatedList Create(IEnumerable source, int pageNumber, int pageSize) 51 | { 52 | var count = source.Count(); 53 | 54 | var items = source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList(); 55 | 56 | return new PaginatedList(items, count, pageNumber, pageSize); 57 | } 58 | 59 | public static PaginatedList Create(List source, int totalCount, int pageNumber, int pageSize) 60 | { 61 | return new PaginatedList(source, totalCount, pageNumber, pageSize); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Models/ResponseDto.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.ValueObjects; 2 | 3 | namespace AkilliPrompt.WebApi.Models; 4 | 5 | public sealed class ResponseDto 6 | { 7 | public T? Data { get; set; } 8 | public string? Message { get; set; } 9 | public bool IsSuccess { get; set; } 10 | public IReadOnlyList ValidationErrors { get; set; } 11 | 12 | public ResponseDto(T? data, string message, bool isSuccess, IReadOnlyList validationErrors) 13 | { 14 | Data = data; 15 | Message = message; 16 | IsSuccess = isSuccess; 17 | ValidationErrors = validationErrors; 18 | } 19 | 20 | public static ResponseDto Success(T data, string message) 21 | { 22 | return new ResponseDto(data, message, true, []); 23 | } 24 | public static ResponseDto Success(string message) 25 | { 26 | return new ResponseDto(default, message, true, []); 27 | } 28 | 29 | 30 | public static ResponseDto Error(string message, List validationErrors) 31 | { 32 | return new ResponseDto(default, message, false, validationErrors); 33 | } 34 | 35 | public static ResponseDto Error(string message) 36 | { 37 | return new ResponseDto(default, message, false, []); 38 | } 39 | 40 | public static ResponseDto Error(string message, ValidationError validationError) 41 | { 42 | return new ResponseDto(default, message, false, [validationError]); 43 | } 44 | 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Options/ConfigureSwaggerOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AkilliPrompt.WebApi.Filters; 3 | using Asp.Versioning.ApiExplorer; 4 | using Microsoft.Extensions.Options; 5 | using Microsoft.OpenApi.Models; 6 | using Swashbuckle.AspNetCore.Filters; 7 | using Swashbuckle.AspNetCore.SwaggerGen; 8 | 9 | namespace AkilliPrompt.WebApi.Options; 10 | 11 | public sealed class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) : IConfigureNamedOptions 12 | { 13 | public void Configure(string name, SwaggerGenOptions options) 14 | { 15 | options.OperationFilter(); 16 | 17 | string folder = AppContext.BaseDirectory; 18 | 19 | if (Directory.Exists(folder)) 20 | { 21 | foreach (string record in Directory.GetFiles(folder, "*.xml", SearchOption.AllDirectories)) 22 | { 23 | options.IncludeXmlComments(record); 24 | } 25 | } 26 | 27 | options.ExampleFilters(); 28 | 29 | Configure(options); 30 | } 31 | 32 | private OpenApiInfo CreateVersionInfo(ApiVersionDescription description) 33 | { 34 | OpenApiInfo info = new() 35 | { 36 | Title = "AkilliPrompt API", 37 | Description = "AkilliPrompt API", 38 | Version = description.ApiVersion.ToString(), 39 | Contact = new() 40 | { 41 | Name = "Alper Tunga", 42 | Email = "alper.tunga@yazilim.academy", 43 | Url = new("https://github.com/yazilimacademy") 44 | } 45 | }; 46 | 47 | if (description.IsDeprecated) 48 | { 49 | info.Description += " This API version has been deprecated. Please use one of the new APIs available from the explorer."; 50 | } 51 | 52 | return info; 53 | } 54 | 55 | public void Configure(SwaggerGenOptions options) 56 | { 57 | foreach (ApiVersionDescription description in provider.ApiVersionDescriptions) 58 | { 59 | options.SwaggerDoc(description.GroupName, CreateVersionInfo(description)); 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Program.cs: -------------------------------------------------------------------------------- 1 | using Azure.Identity; 2 | using AkilliPrompt.WebApi; 3 | using AkilliPrompt.Persistence; 4 | using Serilog; 5 | using AkilliPrompt.WebApi.Configuration; 6 | using AkilliPrompt.WebApi.Extensions; 7 | using Microsoft.AspNetCore.Mvc; 8 | using AkilliPrompt.WebApi.Filters; 9 | 10 | Log.Logger = new LoggerConfiguration() 11 | .WriteTo.Console() 12 | .WriteTo.File("Logs/log.txt", rollingInterval: RollingInterval.Day) 13 | .CreateLogger(); 14 | 15 | try 16 | { 17 | Log.Information("Starting web application"); 18 | 19 | var builder = WebApplication.CreateBuilder(args); 20 | 21 | builder.Host.UseSerilog(); 22 | 23 | // Add services to the container. 24 | 25 | if (!builder.Environment.IsDevelopment()) 26 | { 27 | builder.Configuration.AddAzureKeyVault( 28 | new Uri(builder.Configuration["AzureKeyVaultSettings:Uri"]), 29 | new ClientSecretCredential( 30 | tenantId: builder.Configuration["AzureKeyVaultSettings:TenantId"], 31 | clientId: builder.Configuration["AzureKeyVaultSettings:ClientId"], 32 | clientSecret: builder.Configuration["AzureKeyVaultSettings:ClientSecret"] 33 | )); 34 | } 35 | 36 | builder.Services.AddPersistence(builder.Configuration); 37 | builder.Services.AddWebApi(builder.Configuration); 38 | 39 | // Suppress model state validation suppression 40 | builder.Services.Configure(options => 41 | { 42 | options.SuppressModelStateInvalidFilter = true; 43 | }); 44 | 45 | builder.Services.AddControllers(options =>{ 46 | options.Filters.Add(); 47 | }); 48 | 49 | 50 | // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi 51 | builder.Services.AddOpenApi(); 52 | 53 | var app = builder.Build(); 54 | 55 | 56 | app.UseCors("AllowAll"); 57 | 58 | // Configure the HTTP request pipeline. 59 | if (app.Environment.IsDevelopment()) 60 | { 61 | app.MapOpenApi(); 62 | app.UseSwaggerWithVersion(); 63 | app.UseDeveloperExceptionPage(); 64 | } 65 | else 66 | { 67 | app.UseHsts(); 68 | } 69 | 70 | app.UseHttpsRedirection(); 71 | 72 | app.UseStaticFiles(); 73 | 74 | app.UseAuthentication(); 75 | 76 | app.UseAuthorization(); 77 | 78 | app.MapControllers(); 79 | 80 | app.ApplyMigrations(); 81 | 82 | app.Run(); 83 | } 84 | catch (Exception ex) 85 | { 86 | Log.Fatal(ex, "Application terminated unexpectedly"); 87 | } 88 | finally 89 | { 90 | Log.CloseAndFlush(); 91 | } 92 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "http": { 4 | "commandName": "Project", 5 | "environmentVariables": { 6 | "ASPNETCORE_ENVIRONMENT": "Development" 7 | }, 8 | "dotnetRunMessages": true, 9 | "applicationUrl": "http://localhost:5058" 10 | }, 11 | "https": { 12 | "commandName": "Project", 13 | "environmentVariables": { 14 | "ASPNETCORE_ENVIRONMENT": "Development" 15 | }, 16 | "dotnetRunMessages": true, 17 | "applicationUrl": "https://localhost:7280;http://localhost:5058" 18 | }, 19 | "Container (Dockerfile)": { 20 | "commandName": "Docker", 21 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", 22 | "environmentVariables": { 23 | "ASPNETCORE_HTTPS_PORTS": "8081", 24 | "ASPNETCORE_HTTP_PORTS": "8080" 25 | }, 26 | "publishAllPorts": true, 27 | "useSSL": true 28 | } 29 | }, 30 | "$schema": "https://json.schemastore.org/launchsettings.json" 31 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Services/CacheInvalidator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Distributed; 2 | using StackExchange.Redis; 3 | 4 | namespace AkilliPrompt.WebApi.Services; 5 | 6 | public class CacheInvalidator 7 | { 8 | private readonly IDistributedCache _cache; 9 | private readonly ILogger _logger; 10 | private readonly IDatabase _redisDb; 11 | 12 | public CacheInvalidator( 13 | IDistributedCache cache, 14 | IConnectionMultiplexer redis, 15 | ILogger logger) 16 | { 17 | _cache = cache; 18 | _redisDb = redis.GetDatabase(); 19 | _logger = logger; 20 | } 21 | 22 | public async Task InvalidateAsync(string cacheKey, CancellationToken cancellationToken) 23 | { 24 | await _cache.RemoveAsync(cacheKey, cancellationToken); 25 | 26 | _logger.LogInformation($"[Cache Invalidate] Key: {cacheKey}"); 27 | } 28 | 29 | // Muhammet bizi uyardi. Pattern'e uygun key'leri silen bir script yazmanin daha iyi oldugun iletti. 30 | // Bu duruma uygulamayi deploy ettikten sonra bir bakalim. 31 | public async Task InvalidateGroupAsync(string cacheGroup, CancellationToken cancellationToken) 32 | { 33 | try 34 | { 35 | ArgumentException.ThrowIfNullOrEmpty(cacheGroup); 36 | 37 | var groupSetKey = CreateGroupKey(cacheGroup); 38 | 39 | var cacheKeys = await _redisDb.SetMembersAsync(groupSetKey); 40 | 41 | // Use parallel processing for better performance with large sets 42 | var tasks = cacheKeys.Select(async key => 43 | { 44 | try 45 | { 46 | await _cache.RemoveAsync(key, cancellationToken); 47 | 48 | _logger.LogInformation("Cache key {Key} from group {Group} invalidated", key, cacheGroup); 49 | } 50 | catch (Exception ex) 51 | { 52 | _logger.LogError(ex, "Failed to invalidate cache key {Key} from group {Group}", key, cacheGroup); 53 | } 54 | }); 55 | 56 | await Task.WhenAll(tasks); 57 | await _redisDb.KeyDeleteAsync(groupSetKey); 58 | } 59 | catch (Exception ex) 60 | { 61 | _logger.LogError(ex, "Failed to invalidate cache group {Group}", cacheGroup); 62 | throw; 63 | } 64 | } 65 | 66 | // Extract string literals to constants 67 | private const string GroupKeyPrefix = "Group:"; 68 | private static string CreateGroupKey(string group) => $"{GroupKeyPrefix}{group}"; 69 | } 70 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Services/CacheKeyFactory.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text; 3 | using AkilliPrompt.Domain.Common; 4 | using AkilliPrompt.WebApi.Attributes; 5 | using StackExchange.Redis; 6 | 7 | namespace AkilliPrompt.WebApi.Services; 8 | 9 | public class CacheKeyFactory 10 | { 11 | private readonly IDatabase _redisDb; 12 | 13 | public CacheKeyFactory(IConnectionMultiplexer connectionMultiplexer) 14 | { 15 | _redisDb = connectionMultiplexer.GetDatabase(); 16 | } 17 | 18 | /// 19 | /// Cache key'i oluşturur. 20 | /// 21 | /// Cache edilebilir istek tipi. 22 | /// Cache edilecek istek. 23 | /// Oluşturulan cache key'i. 24 | public string CreateCacheKey(TRequest request) where TRequest : ICacheable 25 | { 26 | // İsteğin tip adını alır. 27 | var typeName = typeof(TRequest).Name; 28 | 29 | // İsteğin CacheGroup özelliğini alır. 30 | var cacheGroup = request.CacheGroup; 31 | 32 | // CacheKeyPartAttribute özniteliğine sahip tüm özellikleri alır ve adlarına göre sıralar. 33 | var properties = typeof(TRequest).GetProperties(BindingFlags.Public | BindingFlags.Instance) 34 | .Where(p => p.GetCustomAttribute() != null) 35 | .OrderBy(p => p.Name); 36 | 37 | // Cache key'ini oluşturacak StringBuilder nesnesi. 38 | var keyBuilder = new StringBuilder(); 39 | 40 | // Key'e istek tip adını ekler. 41 | keyBuilder.Append(typeName); 42 | 43 | // Her özellik için: 44 | foreach (var prop in properties) 45 | { 46 | // Özelliğin CacheKeyPartAttribute özniteliğini alır. 47 | var attr = prop.GetCustomAttribute(); 48 | if (attr == null) 49 | { 50 | continue; // Skip properties without the attribute 51 | } 52 | 53 | // Özelliğin değerini alır. 54 | var value = prop.GetValue(request); 55 | 56 | // Değeri normalize eder. 57 | string normalizedValue = NormalizeValue(value, attr); 58 | 59 | // Normalize edilmiş değeri key'e ekler. 60 | keyBuilder.Append($"_{attr.Prefix}{normalizedValue}"); 61 | } 62 | 63 | // Oluşturulan cache key'ini döndürür. Örnek: GetAllGameRegionsQuery_123_Turkiye_tr 64 | var cacheKey = keyBuilder.ToString(); 65 | 66 | // Add the cache key to the group set in Redis 67 | if (!string.IsNullOrEmpty(cacheGroup)) 68 | { 69 | var groupSetKey = $"Group:{cacheGroup}"; 70 | 71 | _redisDb.SetAdd(groupSetKey, cacheKey); 72 | } 73 | 74 | return cacheKey; 75 | } 76 | 77 | /// 78 | /// Verilen değeri normalize eder. Null değerler için "null" döndürür, aksi takdirde değeri stringe çevirir ve istenirse Uri.EscapeDataString ile encode eder. 79 | /// 80 | /// Normalize edilecek değer. 81 | /// CacheKeyPartAttribute özniteliği. 82 | /// Normalize edilmiş değer. 83 | private string NormalizeValue(object? value, CacheKeyPartAttribute attr) 84 | { 85 | // Değer null ise "null" döndürür. 86 | if (value is null) 87 | return "null"; 88 | 89 | // Değeri stringe çevirir, null ise "null" döndürür. 90 | string stringValue = value.ToString() ?? "null"; 91 | 92 | // Encode özelliği aktif ise Uri.EscapeDataString ile encode eder. 93 | if (attr.Encode) 94 | { 95 | stringValue = Uri.EscapeDataString(stringValue); 96 | } 97 | 98 | // Normalize edilmiş değeri döndürür. 99 | return stringValue; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Services/CurrentUserManager.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Helpers; 2 | using AkilliPrompt.Persistence.Services; 3 | 4 | namespace AkilliPrompt.WebApi.Services; 5 | 6 | public sealed class CurrentUserManager : ICurrentUserService 7 | { 8 | private readonly IHttpContextAccessor _httpContextAccessor; 9 | private readonly IWebHostEnvironment _env; 10 | 11 | public CurrentUserManager(IHttpContextAccessor httpContextAccessor, IWebHostEnvironment env) 12 | { 13 | _httpContextAccessor = httpContextAccessor; 14 | _env = env; 15 | } 16 | 17 | // public long? UserId => GetUserId(); 18 | public Guid UserId => GetUserId(); 19 | 20 | public string IpAddress => GetIpAddress(); 21 | 22 | private Guid GetUserId() 23 | { 24 | var userId = _httpContextAccessor.HttpContext?.User.FindFirst("uid")?.Value; 25 | 26 | return userId is null ? Guid.Empty : Guid.Parse(userId); 27 | } 28 | 29 | private string GetIpAddress() 30 | { 31 | if (_env.IsDevelopment()) 32 | return IpHelper.GetIpAddress(); 33 | 34 | if (_httpContextAccessor.HttpContext.Request.Headers.ContainsKey("X-Forwarded-For")) 35 | return _httpContextAccessor.HttpContext.Request.Headers["X-Forwarded-For"]; 36 | else 37 | return _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Services/ExistenceManager.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Caching.Distributed; 5 | using System.Text.Json; 6 | 7 | namespace AkilliPrompt.WebApi.Services; 8 | 9 | /// 10 | /// Generic service to check existence of entities with caching. 11 | /// 12 | /// Type of the entity. 13 | public class ExistenceManager : IExistenceService 14 | where TEntity : EntityBase 15 | { 16 | private readonly ApplicationDbContext _context; 17 | private readonly IDistributedCache _cache; 18 | private readonly ILogger> _logger; 19 | private const string CacheKeyPrefix = "EntityExists:"; 20 | 21 | public ExistenceManager( 22 | ApplicationDbContext context, 23 | IDistributedCache cache, 24 | ILogger> logger) 25 | { 26 | _context = context; 27 | _cache = cache; 28 | _logger = logger; 29 | } 30 | 31 | /// 32 | public async Task ExistsAsync(Guid id, CancellationToken cancellationToken) 33 | { 34 | var cacheKey = GenerateCacheKey(id); 35 | 36 | try 37 | { 38 | // Attempt to retrieve from cache 39 | var cachedValue = await _cache.GetStringAsync(cacheKey, cancellationToken); 40 | 41 | if (!string.IsNullOrEmpty(cachedValue)) 42 | { 43 | if (bool.TryParse(cachedValue, out bool exists)) 44 | { 45 | _logger.LogInformation($"[Cache Hit] {typeof(TEntity).Name} ID: {id}"); 46 | return exists; 47 | } 48 | } 49 | 50 | _logger.LogInformation($"[Cache Miss] {typeof(TEntity).Name} ID: {id}. Querying database."); 51 | 52 | // Query the database 53 | bool existsInDb = await _context.Set() 54 | .AnyAsync(e => e.Id == id, cancellationToken); 55 | 56 | // Cache the result 57 | await SetExistenceAsync(id, existsInDb, cancellationToken); 58 | 59 | return existsInDb; 60 | } 61 | catch (Exception ex) 62 | { 63 | _logger.LogError(ex, $"Error checking existence of {typeof(TEntity).Name} ID: {id}"); 64 | // Fallback to database query if cache fails 65 | return await _context.Set() 66 | .AnyAsync(e => e.Id == id, cancellationToken); 67 | } 68 | } 69 | 70 | /// 71 | public async Task SetExistenceAsync(Guid id, bool exists, CancellationToken cancellationToken) 72 | { 73 | var cacheKey = GenerateCacheKey(id); 74 | 75 | var serializedValue = JsonSerializer.Serialize(exists); 76 | 77 | try 78 | { 79 | await _cache.SetStringAsync(cacheKey, serializedValue, new DistributedCacheEntryOptions 80 | { 81 | AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) 82 | }, cancellationToken); 83 | 84 | _logger.LogInformation($"[Cache Set] {typeof(TEntity).Name} ID: {id} exists: {exists}"); 85 | } 86 | catch (Exception ex) 87 | { 88 | _logger.LogError(ex, $"Error setting cache for {typeof(TEntity).Name} ID: {id}"); 89 | } 90 | } 91 | 92 | /// 93 | public async Task RemoveExistenceAsync(Guid id, CancellationToken cancellationToken) 94 | { 95 | var cacheKey = GenerateCacheKey(id); 96 | 97 | try 98 | { 99 | await _cache.RemoveAsync(cacheKey, cancellationToken); 100 | 101 | _logger.LogInformation($"[Cache Removed] {typeof(TEntity).Name} ID: {id}"); 102 | } 103 | catch (Exception ex) 104 | { 105 | _logger.LogError(ex, $"Error removing cache for {typeof(TEntity).Name} ID: {id}"); 106 | } 107 | } 108 | 109 | /// 110 | /// Generates a cache key based on the entity type and its identifier. 111 | /// 112 | /// Identifier of the entity. 113 | /// Generated cache key. 114 | private string GenerateCacheKey(Guid id) => $"{CacheKeyPrefix}{typeof(TEntity).Name}:{id}"; 115 | } 116 | 117 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Services/IExistenceService.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | 3 | namespace AkilliPrompt.WebApi.Services; 4 | 5 | /// 6 | /// Provides methods to check the existence of entities with caching. 7 | /// 8 | /// Type of the entity. 9 | public interface IExistenceService 10 | where TEntity : EntityBase 11 | { 12 | /// 13 | /// Checks if an entity with the specified ID exists. 14 | /// 15 | /// Identifier of the entity. 16 | /// Cancellation token. 17 | /// True if exists; otherwise, false. 18 | Task ExistsAsync(Guid id, CancellationToken cancellationToken); 19 | 20 | /// 21 | /// Adds or updates the existence status of an entity in the cache. 22 | /// 23 | /// Identifier of the entity. 24 | /// Existence status. 25 | /// Cancellation token. 26 | /// A task representing the asynchronous operation. 27 | Task SetExistenceAsync(Guid id, bool exists, CancellationToken cancellationToken); 28 | 29 | /// 30 | /// Removes the existence status of an entity from the cache. 31 | /// 32 | /// Identifier of the entity. 33 | /// Cancellation token. 34 | /// A task representing the asynchronous operation. 35 | Task RemoveExistenceAsync(Guid id, CancellationToken cancellationToken); 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Services/JwtManager.cs: -------------------------------------------------------------------------------- 1 | using System.IdentityModel.Tokens.Jwt; 2 | using System.Security.Authentication; 3 | using System.Security.Claims; 4 | using System.Text; 5 | using AkilliPrompt.Domain.Identity; 6 | using AkilliPrompt.Domain.Settings; 7 | using AkilliPrompt.Domain.ValueObjects; 8 | using Microsoft.Extensions.Options; 9 | using Microsoft.IdentityModel.Tokens; 10 | 11 | namespace IAPriceTrackerApp.WebApi.Services; 12 | 13 | public class JwtManager 14 | { 15 | private readonly JwtSettings _jwtSettings; 16 | 17 | public JwtManager(IOptions jwtSettings) 18 | { 19 | _jwtSettings = jwtSettings.Value; 20 | } 21 | 22 | // Generates a JSON Web Token (JWT) for user authentication. 23 | public AccessToken GenerateToken(ApplicationUser applicationUser, IList roles) 24 | { 25 | // Get the access token expiration time from settings. 26 | var expirationInMinutes = _jwtSettings.AccessTokenExpiration; 27 | 28 | // Calculate the expiration date of the token. 29 | var expirationDate = DateTime.UtcNow.Add(expirationInMinutes); 30 | 31 | // Define the claims for the JWT. These are pieces of information about the user. 32 | var claims = new List 33 | { 34 | // Unique user identifier. 35 | new Claim("uid", applicationUser.Id.ToString()), 36 | // User's email address. 37 | new Claim(JwtRegisteredClaimNames.Email, applicationUser.Email), 38 | // User's first name. 39 | new Claim(JwtRegisteredClaimNames.GivenName, applicationUser.FullName.FirstName), 40 | // User's last name. 41 | new Claim(JwtRegisteredClaimNames.FamilyName, applicationUser.FullName.LastName), 42 | // Unique identifier for the JWT. 43 | new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), 44 | // Expiration time of the JWT. 45 | new Claim(JwtRegisteredClaimNames.Exp, expirationDate.ToFileTimeUtc().ToString()), 46 | // Issued at time of the JWT. 47 | new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), 48 | } 49 | // Add user roles to the claims. 50 | .Union(roles.Select(role => new Claim(ClaimTypes.Role, role))); 51 | 52 | // Create a symmetric security key using the secret key from settings. This is used to sign the token. 53 | var symmetricSecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)); 54 | 55 | // Create signing credentials using the security key and HMACSHA256 algorithm. 56 | var signingCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256); 57 | 58 | // Create the JWT using the claims, expiration date, and signing credentials. 59 | var jwtSecurityToken = new JwtSecurityToken( 60 | issuer: _jwtSettings.Issuer, 61 | audience: _jwtSettings.Audience, 62 | claims: claims, 63 | expires: expirationDate, 64 | signingCredentials: signingCredentials 65 | ); 66 | 67 | // Convert the JWT to a string. 68 | var token = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken); 69 | 70 | // Return the access token with its expiration date. 71 | return new AccessToken(token, expirationDate); 72 | } 73 | 74 | // Extracts the user ID from a JWT. 75 | public long GetUserIdFromJwt(string token) 76 | { 77 | try 78 | { 79 | // Parse the claims from the JWT. 80 | var claims = ParseClaimsFromJwt(token); 81 | 82 | // Get the user ID claim. 83 | var userId = claims.FirstOrDefault(c => c.Type == "uid")?.Value; 84 | 85 | // Throw an exception if the user ID is not found. 86 | if (string.IsNullOrWhiteSpace(userId)) 87 | throw new AuthenticationException("Invalid token"); 88 | 89 | // Parse the user ID from the claim and return it. 90 | return long.Parse(userId); 91 | } 92 | catch (Exception ex) 93 | { 94 | // Throw an authentication exception if there is an error. 95 | throw new AuthenticationException("Invalid token", ex); 96 | } 97 | } 98 | 99 | // Validates a JWT. 100 | public bool ValidateToken(string token) 101 | { 102 | var tokenHandler = new JwtSecurityTokenHandler(); 103 | 104 | var secretKey = Encoding.UTF8.GetBytes(_jwtSettings.SecretKey); 105 | 106 | try 107 | { 108 | // Validate the token using the secret key and validation parameters. Note that ValidateLifetime is set to false. 109 | tokenHandler.ValidateToken(token, new TokenValidationParameters 110 | { 111 | ValidateIssuerSigningKey = true, 112 | IssuerSigningKey = new SymmetricSecurityKey(secretKey), 113 | ValidateIssuer = true, 114 | ValidIssuer = _jwtSettings.Issuer, 115 | ValidateAudience = true, 116 | ValidAudience = _jwtSettings.Audience, 117 | ValidateLifetime = false, // We'll handle expiration separately. 118 | ClockSkew = TimeSpan.Zero 119 | }, out SecurityToken validatedToken); 120 | 121 | return true; 122 | } 123 | catch 124 | { 125 | return false; 126 | } 127 | } 128 | 129 | // Parses the claims from a JWT payload. 130 | private IEnumerable ParseClaimsFromJwt(string jwt) 131 | { 132 | // Split the JWT into segments and extract the payload. 133 | var payload = jwt.Split('.')[1]; 134 | // Decode the base64 encoded payload. 135 | var jsonBytes = ParseBase64WithoutPadding(payload); 136 | // Deserialize the payload into a dictionary. 137 | var keyValuePairs = System.Text.Json.JsonSerializer.Deserialize>(jsonBytes); 138 | 139 | // Convert the key-value pairs into claims. 140 | return keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())); 141 | } 142 | 143 | // Adds padding to a base64 string if necessary. 144 | private byte[] ParseBase64WithoutPadding(string base64) 145 | { 146 | switch (base64.Length % 4) 147 | { 148 | case 2: base64 += "=="; break; 149 | case 3: base64 += "="; break; 150 | } 151 | return Convert.FromBase64String(base64); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/Services/R2ObjectStorageManager.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using AkilliPrompt.Domain.Settings; 3 | using AkilliPrompt.Persistence.Services; 4 | using Amazon.Runtime; 5 | using Amazon.S3; 6 | using Amazon.S3.Model; 7 | using Microsoft.Extensions.Options; 8 | using TSID.Creator.NET; 9 | 10 | namespace AkilliPrompt.WebApi.Services; 11 | 12 | public sealed class R2ObjectStorageManager 13 | { 14 | private readonly CloudflareR2Settings _cloudflareR2Settings; 15 | private readonly IAmazonS3 _s3Client; 16 | private readonly ICurrentUserService _currentUserService; 17 | 18 | public R2ObjectStorageManager( 19 | IOptions cloudflareR2Settings, 20 | ICurrentUserService currentUserService) 21 | { 22 | _cloudflareR2Settings = cloudflareR2Settings.Value; 23 | 24 | var credentials = new BasicAWSCredentials( 25 | _cloudflareR2Settings.AccessKey, 26 | _cloudflareR2Settings.SecretKey); 27 | 28 | _s3Client = new AmazonS3Client(credentials, 29 | new AmazonS3Config 30 | { 31 | ServiceURL = _cloudflareR2Settings.ServiceUrl 32 | }); 33 | 34 | _currentUserService = currentUserService; 35 | } 36 | 37 | public async Task UploadPromptPicAsync(IFormFile file, long promptId, CancellationToken cancellationToken) 38 | { 39 | var fileExtension = Path.GetExtension(file.FileName); 40 | 41 | var key = $"{TsidCreator.GetTsid()}{fileExtension}"; 42 | 43 | var request = new PutObjectRequest 44 | { 45 | Key = key, 46 | InputStream = file.OpenReadStream(), 47 | BucketName = _cloudflareR2Settings.PromptPicsBucketName, 48 | DisablePayloadSigning = true, 49 | }; 50 | 51 | request.Metadata.Add("promptId", promptId.ToString()); 52 | request.Metadata.Add("userId", _currentUserService.UserId.ToString()); 53 | 54 | var response = await _s3Client.PutObjectAsync(request, cancellationToken); 55 | 56 | if (response.HttpStatusCode != HttpStatusCode.OK) 57 | throw new Exception($"Failed to upload prompt pic. Status code: {response.HttpStatusCode}"); 58 | 59 | return key; 60 | } 61 | 62 | public async Task UploadPromptPicAsync(IFormFile file, CancellationToken cancellationToken) 63 | { 64 | var fileExtension = Path.GetExtension(file.FileName); 65 | 66 | var key = $"{TsidCreator.GetTsid()}{fileExtension}"; 67 | 68 | var request = new PutObjectRequest 69 | { 70 | Key = key, 71 | InputStream = file.OpenReadStream(), 72 | BucketName = _cloudflareR2Settings.PromptPicsBucketName, 73 | DisablePayloadSigning = true, 74 | }; 75 | 76 | request.Metadata.Add("userId", _currentUserService.UserId.ToString()); 77 | 78 | var response = await _s3Client.PutObjectAsync(request, cancellationToken); 79 | 80 | if (response.HttpStatusCode != HttpStatusCode.OK) 81 | throw new Exception($"Failed to upload prompt pic. Status code: {response.HttpStatusCode}"); 82 | 83 | return key; 84 | } 85 | 86 | public async Task RemovePromptPicAsync(string key, CancellationToken cancellationToken) 87 | { 88 | var request = new DeleteObjectRequest 89 | { 90 | BucketName = _cloudflareR2Settings.PromptPicsBucketName, 91 | Key = key 92 | }; 93 | 94 | var response = await _s3Client.DeleteObjectAsync(request, cancellationToken); 95 | 96 | if (response.HttpStatusCode != HttpStatusCode.NoContent) 97 | throw new Exception($"Failed to delete prompt pic. Status code: {response.HttpStatusCode}"); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Auth/AuthController.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.V1.Auth.Commands.GoogleLogin; 2 | using MediatR; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Auth; 6 | 7 | [ApiController] 8 | [Route("v{version:apiVersion}/[controller]")] 9 | public class AuthController : ControllerBase 10 | { 11 | private readonly ISender _mediator; 12 | 13 | public AuthController(ISender mediator) 14 | { 15 | _mediator = mediator; 16 | } 17 | 18 | [HttpPost("google-login")] 19 | public async Task GoogleLoginAsync(GoogleLoginCommand command, CancellationToken cancellationToken) 20 | { 21 | return Ok(await _mediator.Send(command, cancellationToken)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Auth/Commands/GoogleLogin/GoogleLoginCommand.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Models; 2 | using MediatR; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Auth.Commands.GoogleLogin; 5 | 6 | public sealed record GoogleLoginCommand(string GoogleToken) : IRequest>; -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Auth/Commands/GoogleLogin/GoogleLoginCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Identity; 2 | using AkilliPrompt.Domain.ValueObjects; 3 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 4 | using AkilliPrompt.WebApi.Models; 5 | using Google.Apis.Auth; 6 | using IAPriceTrackerApp.WebApi.Services; 7 | using MediatR; 8 | using Microsoft.AspNetCore.Identity; 9 | using FluentValidation; 10 | using FluentValidation.Results; 11 | using AkilliPrompt.Domain.Settings; 12 | using Microsoft.Extensions.Options; 13 | using System.Text.Json; 14 | using AkilliPrompt.Persistence.Services; 15 | using AkilliPrompt.Domain.Constants; 16 | 17 | namespace AkilliPrompt.WebApi.V1.Auth.Commands.GoogleLogin; 18 | 19 | public sealed class GoogleLoginCommandHandler : IRequestHandler> 20 | { 21 | private readonly UserManager _userManager; 22 | private readonly JwtManager _jwtManager; 23 | private readonly ApplicationDbContext _dbContext; 24 | private readonly JwtSettings _jwtSettings; 25 | private readonly ICurrentUserService _currentUserService; 26 | 27 | public GoogleLoginCommandHandler( 28 | UserManager userManager, 29 | JwtManager jwtManager, 30 | ApplicationDbContext dbContext, 31 | ICurrentUserService currentUserService, 32 | IOptions jwtSettings) 33 | { 34 | _userManager = userManager; 35 | _jwtManager = jwtManager; 36 | _dbContext = dbContext; 37 | _currentUserService = currentUserService; 38 | _jwtSettings = jwtSettings.Value; 39 | } 40 | 41 | public async Task> Handle(GoogleLoginCommand request, CancellationToken cancellationToken) 42 | { 43 | // Get payload (validation already done by validator) 44 | var payload = await GoogleJsonWebSignature.ValidateAsync(request.GoogleToken); 45 | 46 | Console.WriteLine(JsonSerializer.Serialize(payload)); 47 | 48 | // Check if user exists 49 | var user = await _userManager.FindByEmailAsync(payload.Email); 50 | 51 | if (user is null) 52 | { 53 | var fullName = new FullName(payload.GivenName, payload.FamilyName); 54 | 55 | // Register new user 56 | user = ApplicationUser.Create(payload.Email, fullName, isEmailConfirmed: true); 57 | 58 | var result = await _userManager.CreateAsync(user); 59 | 60 | ThrowIfIdentityResultFailed(result); 61 | 62 | await _userManager.AddToRoleAsync(user, RoleConstants.UserRole); 63 | } 64 | 65 | // Generate JWT token 66 | var roles = await _userManager.GetRolesAsync(user); 67 | 68 | var accessToken = _jwtManager.GenerateToken(user, roles); 69 | 70 | // Generate refresh token 71 | var refreshToken = new RefreshToken(Guid.CreateVersion7().ToString(), DateTime.UtcNow.Add(_jwtSettings.RefreshTokenExpiration)); 72 | 73 | // Store refresh token in database 74 | var refreshTokenEntity = CreateRefreshToken(user, refreshToken); 75 | 76 | _dbContext.RefreshTokens.Add(refreshTokenEntity); 77 | 78 | await _dbContext.SaveChangesAsync(cancellationToken); 79 | 80 | return ResponseDto.Success( 81 | new GoogleLoginDto(accessToken, refreshToken), 82 | "Login successful"); 83 | 84 | } 85 | 86 | private static void ThrowIfIdentityResultFailed(IdentityResult result) 87 | { 88 | if (!result.Succeeded) 89 | { 90 | var failures = result.Errors.Select(error => new ValidationFailure(error.Code, error.Description)); 91 | throw new ValidationException(failures); 92 | } 93 | } 94 | 95 | private Domain.Entities.RefreshToken CreateRefreshToken(ApplicationUser user, RefreshToken refreshToken) 96 | { 97 | return new Domain.Entities.RefreshToken 98 | { 99 | Token = refreshToken.Value, 100 | Expires = refreshToken.ExpiresOnUtc, 101 | CreatedByIp = _currentUserService.IpAddress, 102 | SecurityStamp = user.SecurityStamp!, 103 | UserId = user.Id, 104 | Id = Guid.CreateVersion7(), 105 | }; 106 | } 107 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Auth/Commands/GoogleLogin/GoogleLoginCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Settings; 2 | using FluentValidation; 3 | using Google.Apis.Auth; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace AkilliPrompt.WebApi.V1.Auth.Commands.GoogleLogin; 7 | 8 | public sealed class GoogleLoginCommandValidator : AbstractValidator 9 | { 10 | private readonly GoogleJsonWebSignature.ValidationSettings _googleSettings; 11 | 12 | public GoogleLoginCommandValidator(IOptions googleSettings) 13 | { 14 | _googleSettings = new GoogleJsonWebSignature.ValidationSettings 15 | { 16 | Audience = new[] { googleSettings.Value.ClientId } 17 | }; 18 | 19 | RuleFor(x => x.GoogleToken) 20 | .NotEmpty() 21 | .WithMessage("Google token cannot be empty.") 22 | .MustAsync(ValidateGoogleTokenAsync) 23 | .WithMessage("Invalid Google token."); 24 | } 25 | 26 | private async Task ValidateGoogleTokenAsync(string token, CancellationToken cancellationToken) 27 | { 28 | try 29 | { 30 | var payload = await GoogleJsonWebSignature.ValidateAsync(token, _googleSettings); 31 | 32 | return true; 33 | } 34 | catch (InvalidJwtException ex) 35 | { 36 | return Failure($"Token validation failed: {ex.Message}"); 37 | } 38 | } 39 | 40 | private bool Failure(string message) 41 | { 42 | // Add custom error message to validation context 43 | var context = new ValidationContext(null); 44 | context.AddFailure("GoogleToken", message); 45 | return false; 46 | } 47 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Auth/Commands/GoogleLogin/GoogleLoginDto.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.ValueObjects; 2 | 3 | namespace AkilliPrompt.WebApi.V1.Auth.Commands.GoogleLogin; 4 | 5 | public record GoogleLoginDto(AccessToken AccessToken, RefreshToken RefreshToken); 6 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/CategoriesController.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using AkilliPrompt.WebApi.Helpers; 3 | using AkilliPrompt.WebApi.Models; 4 | using AkilliPrompt.WebApi.V1.Categories.Commands.Create; 5 | using AkilliPrompt.WebApi.V1.Categories.Commands.Delete; 6 | using AkilliPrompt.WebApi.V1.Categories.Commands.Update; 7 | using AkilliPrompt.WebApi.V1.Categories.Queries.GetAll; 8 | using AkilliPrompt.WebApi.V1.Categories.Queries.GetById; 9 | using Asp.Versioning; 10 | using MediatR; 11 | using Microsoft.AspNetCore.Authorization; 12 | using Microsoft.AspNetCore.Mvc; 13 | using RouteAttribute = Microsoft.AspNetCore.Mvc.RouteAttribute; 14 | 15 | namespace AkilliPrompt.WebApi.V1.Categories; 16 | 17 | [ApiController] 18 | [ApiVersion("1.0")] 19 | [Route("v{version:apiVersion}/[controller]")] 20 | [Authorize] 21 | public sealed class CategoriesController : ControllerBase 22 | { 23 | private readonly ApplicationDbContext _dbContext; 24 | private readonly ISender _mediator; 25 | 26 | public CategoriesController( 27 | ApplicationDbContext dbContext, 28 | ISender mediator) 29 | { 30 | _dbContext = dbContext; 31 | _mediator = mediator; 32 | } 33 | 34 | [HttpGet] 35 | public async Task GetAllAsync(CancellationToken cancellationToken) 36 | { 37 | return Ok(await _mediator.Send(new GetAllCategoriesQuery(), cancellationToken)); 38 | } 39 | 40 | [HttpGet("{id:guid}")] 41 | public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) 42 | { 43 | return Ok(await _mediator.Send(new GetByIdCategoryQuery(id), cancellationToken)); 44 | } 45 | 46 | [HttpPost] 47 | [Authorize(Roles = "Admin")] 48 | public async Task CreateAsync(CreateCategoryCommand command, CancellationToken cancellationToken) 49 | { 50 | return Ok(await _mediator.Send(command, cancellationToken)); 51 | } 52 | 53 | [HttpPut("{id:guid}")] 54 | [Authorize(Roles = "Admin")] 55 | public async Task UpdateAsync(Guid id, UpdateCategoryCommand command, CancellationToken cancellationToken) 56 | { 57 | if (command.Id != id) 58 | return BadRequest(); 59 | 60 | return Ok(await _mediator.Send(command, cancellationToken)); 61 | } 62 | 63 | [HttpDelete("{id:guid}")] 64 | [Authorize(Roles = "Admin")] 65 | public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) 66 | { 67 | return Ok(await _mediator.Send(new DeleteCategoryCommand(id), cancellationToken)); 68 | } 69 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Create/CreateCategoryCommand.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Models; 2 | using MediatR; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Create; 5 | 6 | public sealed record CreateCategoryCommand(string Name, string Description) : IRequest>; 7 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Create/CreateCategoryCommandHandler.cs: -------------------------------------------------------------------------------- 1 | 2 | using AkilliPrompt.Domain.Entities; 3 | using AkilliPrompt.Domain.ValueObjects; 4 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 5 | using AkilliPrompt.WebApi.Helpers; 6 | using AkilliPrompt.WebApi.Models; 7 | using AkilliPrompt.WebApi.Services; 8 | using MediatR; 9 | 10 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Create; 11 | 12 | public sealed class CreateCategoryCommandHandler : IRequestHandler> 13 | { 14 | private readonly ApplicationDbContext _dbContext; 15 | private readonly CacheInvalidator _cacheInvalidator; 16 | public CreateCategoryCommandHandler(ApplicationDbContext dbContext, CacheInvalidator cacheInvalidator) 17 | { 18 | _dbContext = dbContext; 19 | _cacheInvalidator = cacheInvalidator; 20 | } 21 | public async Task> Handle(CreateCategoryCommand request, CancellationToken cancellationToken) 22 | { 23 | var category = Category.Create(request.Name, request.Description); 24 | 25 | _dbContext.Categories.Add(category); 26 | 27 | await _dbContext.SaveChangesAsync(cancellationToken); 28 | 29 | // Invalidate relevant caches 30 | await _cacheInvalidator.InvalidateGroupAsync("Categories", cancellationToken); 31 | 32 | return ResponseDto.Success(category.Id, MessageHelper.GetApiSuccessCreatedMessage("Kategori")); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Create/CreateCategoryCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using FluentValidation; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Create; 6 | 7 | public sealed class CreateCategoryCommandValidator : AbstractValidator 8 | { 9 | private readonly ApplicationDbContext _dbContext; 10 | public CreateCategoryCommandValidator(ApplicationDbContext dbContext) 11 | { 12 | _dbContext = dbContext; 13 | 14 | RuleFor(x => x.Name) 15 | .NotEmpty() 16 | .WithMessage("Kategori adı boş olamaz.") 17 | .MaximumLength(100) 18 | .WithMessage("Kategori adı en fazla 100 karakter olabilir.") 19 | .MinimumLength(2) 20 | .WithMessage("Kategori adı en az 2 karakter olmalıdır."); 21 | 22 | RuleFor(x => x.Description) 23 | .NotEmpty() 24 | .WithMessage("Kategori açıklaması boş olamaz.") 25 | .MaximumLength(500) 26 | .WithMessage("Kategori açıklaması en fazla 500 karakter olabilir.") 27 | .MinimumLength(2) 28 | .WithMessage("Kategori açıklaması en az 2 karakter olmalıdır."); 29 | 30 | RuleFor(x => x.Name) 31 | .MustAsync(BeUniqueNameAsync) 32 | .WithMessage("Bu ada sahip bir kategori zaten mevcuttur."); 33 | } 34 | 35 | private async Task BeUniqueNameAsync(string name, CancellationToken cancellationToken) 36 | { 37 | return !await _dbContext 38 | .Categories 39 | .AnyAsync(x => x.Name.ToLower() == name.ToLower(), cancellationToken); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Delete/DeleteCategoryCommand.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Models; 2 | using MediatR; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Delete; 5 | 6 | public sealed record DeleteCategoryCommand(Guid Id) : IRequest>; 7 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Delete/DeleteCategoryCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using AkilliPrompt.WebApi.Helpers; 3 | using AkilliPrompt.WebApi.Models; 4 | using AkilliPrompt.WebApi.Services; 5 | using MediatR; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Delete; 9 | 10 | public sealed class DeleteCategoryCommandHandler : IRequestHandler> 11 | { 12 | private readonly ApplicationDbContext _dbContext; 13 | private readonly CacheInvalidator _cacheInvalidator; 14 | public DeleteCategoryCommandHandler(ApplicationDbContext dbContext, CacheInvalidator cacheInvalidator) 15 | { 16 | _dbContext = dbContext; 17 | _cacheInvalidator = cacheInvalidator; 18 | } 19 | 20 | public async Task> Handle(DeleteCategoryCommand request, CancellationToken cancellationToken) 21 | { 22 | await _dbContext 23 | .Categories 24 | .Where(x => x.Id == request.Id) 25 | .ExecuteDeleteAsync(cancellationToken); 26 | 27 | await _cacheInvalidator.InvalidateGroupAsync(CacheKeysHelper.GetAllCategoriesKey, cancellationToken); 28 | 29 | return ResponseDto.Success(MessageHelper.GetApiSuccessDeletedMessage("Kategori")); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Delete/DeleteCategoryValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using FluentValidation; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Delete; 6 | 7 | public sealed class DeleteCategoryValidator : AbstractValidator 8 | { 9 | private readonly ApplicationDbContext _dbContext; 10 | public DeleteCategoryValidator(ApplicationDbContext dbContext) 11 | { 12 | _dbContext = dbContext; 13 | 14 | RuleFor(x => x.Id) 15 | .NotEmpty() 16 | .WithMessage("Lutfen bir kategori seciniz."); 17 | 18 | RuleFor(x => x.Id) 19 | .MustAsync(IsCategoryExistsAsync) 20 | .WithMessage("Kategori bulunamadı."); 21 | } 22 | 23 | private Task IsCategoryExistsAsync(Guid id, CancellationToken cancellationToken) 24 | { 25 | return _dbContext 26 | .Categories 27 | .AnyAsync(x => x.Id == id, cancellationToken); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Update/UpdateCategoryCommand.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Models; 2 | using MediatR; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Update; 5 | 6 | public sealed record UpdateCategoryCommand(Guid Id, string Name, string Description) : IRequest>; 7 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Update/UpdateCategoryCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using AkilliPrompt.WebApi.Helpers; 3 | using AkilliPrompt.WebApi.Models; 4 | using AkilliPrompt.WebApi.Services; 5 | using MediatR; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Update; 9 | 10 | public sealed class UpdateCategoryCommandHandler : IRequestHandler> 11 | { 12 | private readonly ApplicationDbContext _dbContext; 13 | private readonly CacheInvalidator _cacheInvalidator; 14 | public UpdateCategoryCommandHandler(ApplicationDbContext dbContext, CacheInvalidator cacheInvalidator) 15 | { 16 | _dbContext = dbContext; 17 | _cacheInvalidator = cacheInvalidator; 18 | } 19 | 20 | public async Task> Handle(UpdateCategoryCommand request, CancellationToken cancellationToken) 21 | { 22 | await _dbContext 23 | .Categories 24 | .Where(x => x.Id == request.Id) 25 | .ExecuteUpdateAsync(x => x.SetProperty(x => x.Name, request.Name) 26 | .SetProperty(x => x.Description, request.Description), cancellationToken); 27 | 28 | // Invalidate relevant caches concurrently 29 | await _cacheInvalidator.InvalidateGroupAsync("Categories", cancellationToken); 30 | 31 | return ResponseDto.Success(request.Id, MessageHelper.GetApiSuccessUpdatedMessage("Kategori")); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Commands/Update/UpdateCategoryCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 3 | using FluentValidation; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace AkilliPrompt.WebApi.V1.Categories.Commands.Update; 7 | 8 | public sealed class UpdateCategoryCommandValidator : AbstractValidator 9 | { 10 | private readonly ApplicationDbContext _dbContext; 11 | public UpdateCategoryCommandValidator(ApplicationDbContext dbContext) 12 | { 13 | _dbContext = dbContext; 14 | 15 | RuleFor(x => x.Id) 16 | .NotEmpty() 17 | .WithMessage("Lutfen bir kategori seciniz."); 18 | 19 | RuleFor(x => x.Id) 20 | .MustAsync(IsCategoryExistsAsync) 21 | .WithMessage("Kategori bulunamadı."); 22 | 23 | RuleFor(x => x.Name) 24 | .NotEmpty() 25 | .WithMessage("Kategori adı boş olamaz.") 26 | .MaximumLength(100) 27 | .WithMessage("Kategori adı en fazla 100 karakter olabilir.") 28 | .MinimumLength(2) 29 | .WithMessage("Kategori adı en az 2 karakter olmalıdır."); 30 | 31 | RuleFor(x => x.Description) 32 | .NotEmpty() 33 | .WithMessage("Kategori açıklaması boş olamaz.") 34 | .MaximumLength(500) 35 | .WithMessage("Kategori açıklaması en fazla 500 karakter olabilir.") 36 | .MinimumLength(2) 37 | .WithMessage("Kategori açıklaması en az 2 karakter olmalıdır."); 38 | 39 | RuleFor(x => x) 40 | .MustAsync(BeUniqueNameAsync) 41 | .WithMessage("Bu ada sahip bir kategori zaten mevcuttur."); 42 | } 43 | 44 | private async Task BeUniqueNameAsync(UpdateCategoryCommand command, CancellationToken cancellationToken) 45 | { 46 | return !await _dbContext 47 | .Categories 48 | .AnyAsync(x => x.Id != command.Id && string.Equals(x.Name, command.Name, StringComparison.OrdinalIgnoreCase), cancellationToken); 49 | } 50 | 51 | private Task IsCategoryExistsAsync(Guid id, CancellationToken cancellationToken) 52 | { 53 | return _dbContext 54 | .Categories 55 | .AnyAsync(x => x.Id == id, cancellationToken); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Queries/GetAll/GetAllCategoriesDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace AkilliPrompt.WebApi.V1.Categories.Queries.GetAll; 4 | 5 | public sealed record GetAllCategoriesDto(Guid Id, string Name); 6 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Queries/GetAll/GetAllCategoriesQuery.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.WebApi.Attributes; 3 | using MediatR; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Categories.Queries.GetAll; 6 | 7 | [CacheOptions(absoluteExpirationMinutes: 960, slidingExpirationMinutes: 120)] 8 | public sealed record GetAllCategoriesQuery : IRequest>, ICacheable 9 | { 10 | public string CacheGroup => "Categories"; 11 | } 12 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Queries/GetAll/GetAllCategoriesQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using MediatR; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Categories.Queries.GetAll; 6 | 7 | public sealed class GetAllCategoriesQueryHandler : IRequestHandler> 8 | { 9 | private readonly ApplicationDbContext _dbContext; 10 | public GetAllCategoriesQueryHandler(ApplicationDbContext dbContext) 11 | { 12 | _dbContext = dbContext; 13 | } 14 | 15 | public Task> Handle(GetAllCategoriesQuery request, CancellationToken cancellationToken) 16 | { 17 | return _dbContext 18 | .Categories 19 | .AsNoTracking() 20 | .Select(c => new GetAllCategoriesDto(c.Id, c.Name)) 21 | .ToListAsync(cancellationToken); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Queries/GetById/GetByIdCategoryDto.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.V1.Categories.Queries.GetById; 2 | 3 | public sealed record GetByIdCategoryDto(Guid Id, string Name, string Description); 4 | 5 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Queries/GetById/GetByIdCategoryQuery.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.WebApi.Attributes; 3 | using MediatR; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Categories.Queries.GetById; 6 | 7 | [CacheOptions(absoluteExpirationMinutes: 960, slidingExpirationMinutes: 120)] 8 | public sealed record GetByIdCategoryQuery : IRequest, ICacheable 9 | { 10 | public string CacheGroup => "Categories"; 11 | 12 | [CacheKeyPart] 13 | public Guid Id { get; set; } 14 | 15 | public GetByIdCategoryQuery(Guid id) 16 | { 17 | Id = id; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Queries/GetById/GetByIdCategoryQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using MediatR; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Categories.Queries.GetById; 6 | 7 | public sealed class GetByIdCategoryQueryHandler : IRequestHandler 8 | { 9 | private readonly ApplicationDbContext _dbContext; 10 | public GetByIdCategoryQueryHandler(ApplicationDbContext dbContext) 11 | { 12 | _dbContext = dbContext; 13 | } 14 | 15 | public Task Handle(GetByIdCategoryQuery request, CancellationToken cancellationToken) 16 | { 17 | return _dbContext 18 | .Categories 19 | .AsNoTracking() 20 | .Where(x => x.Id == request.Id) 21 | .Select(x => new GetByIdCategoryDto(x.Id, x.Name, x.Description)) 22 | .FirstOrDefaultAsync(cancellationToken); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Categories/Queries/GetById/GetByIdCategoryQueryValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using FluentValidation; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Categories.Queries.GetById; 6 | 7 | public sealed class GetByIdCategoryQueryValidator : AbstractValidator 8 | { 9 | private readonly ApplicationDbContext _dbContext; 10 | public GetByIdCategoryQueryValidator(ApplicationDbContext dbContext) 11 | { 12 | _dbContext = dbContext; 13 | 14 | RuleFor(x => x.Id) 15 | .NotEmpty() 16 | .WithMessage("Kategori seçimi zorunludur."); 17 | 18 | RuleFor(x => x.Id) 19 | .MustAsync(IsCategoryExistsAsync) 20 | .WithMessage("Kategori bulunamadı."); 21 | } 22 | 23 | private Task IsCategoryExistsAsync(Guid id, CancellationToken cancellationToken) 24 | { 25 | return _dbContext 26 | .Categories 27 | .AnyAsync(x => x.Id == id, cancellationToken); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/PromptComments/PromptCommentsController.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Models; 2 | using AkilliPrompt.WebApi.V1.PromptComments.Queries.GetAll; 3 | using Asp.Versioning; 4 | using MediatR; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace AkilliPrompt.WebApi.V1.PromptComments 9 | { 10 | [ApiController] 11 | [ApiVersion("1.0")] 12 | [Route("v{version:apiVersion}/[controller]")] 13 | [Authorize] 14 | public class PromptCommentsController : ControllerBase 15 | { 16 | private readonly ISender _mediator; 17 | 18 | public PromptCommentsController(ISender mediator) 19 | { 20 | _mediator = mediator; 21 | } 22 | 23 | [HttpGet] 24 | [ProducesResponseType(typeof(PaginatedList), StatusCodes.Status200OK)] 25 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 26 | [ProducesResponseType(StatusCodes.Status401Unauthorized)] 27 | public async Task GetAll([FromQuery] GetAllPromptCommentsQuery query, CancellationToken cancellationToken = default) 28 | { 29 | return Ok(await _mediator.Send(query, cancellationToken)); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/PromptComments/Queries/GetAll/GetAllPromptCommentsDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AkilliPrompt.Domain.Entities; 3 | using AkilliPrompt.Domain.ValueObjects; 4 | 5 | namespace AkilliPrompt.WebApi.V1.PromptComments.Queries.GetAll; 6 | 7 | public sealed record GetAllPromptCommentsDto 8 | { 9 | public Guid Id { get; set; } 10 | public int Level { get; set; } 11 | public string Content { get; set; } 12 | 13 | public Guid PromptId { get; set; } 14 | 15 | public Guid UserId { get; set; } 16 | public FullName UserFullName { get; set; } 17 | 18 | public Guid? ParentCommentId { get; set; } 19 | public GetAllPromptCommentsDto? ParentComment { get; set; } 20 | 21 | public List ChildComments { get; set; } = []; 22 | 23 | public DateTimeOffset CreatedAt { get; set; } 24 | 25 | public GetAllPromptCommentsDto(int level, string content, Guid promptId, Guid userId, FullName userFullName, Guid? parentCommentId, GetAllPromptCommentsDto? parentComment, List childComments, DateTimeOffset createdAt) 26 | { 27 | Level = level; 28 | Content = content; 29 | PromptId = promptId; 30 | UserId = userId; 31 | UserFullName = userFullName; 32 | ParentCommentId = parentCommentId; 33 | ParentComment = parentComment; 34 | ChildComments = childComments; 35 | CreatedAt = createdAt; 36 | } 37 | 38 | public GetAllPromptCommentsDto(PromptComment promptComment) 39 | { 40 | Level = promptComment.Level; 41 | Content = promptComment.Content; 42 | PromptId = promptComment.PromptId; 43 | UserId = promptComment.UserId; 44 | UserFullName = promptComment.User.FullName; 45 | ParentCommentId = promptComment.ParentCommentId; 46 | ParentComment = promptComment.ParentComment != null ? new GetAllPromptCommentsDto(promptComment.ParentComment) : null; 47 | ChildComments = promptComment.ChildComments.Select(pc => new GetAllPromptCommentsDto(pc)).ToList(); 48 | CreatedAt = promptComment.CreatedAt; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/PromptComments/Queries/GetAll/GetAllPromptCommentsQuery.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.WebApi.Attributes; 3 | using AkilliPrompt.WebApi.Interfaces; 4 | using AkilliPrompt.WebApi.Models; 5 | using MediatR; 6 | 7 | namespace AkilliPrompt.WebApi.V1.PromptComments.Queries.GetAll; 8 | 9 | [CacheOptions(absoluteExpirationMinutes: 600, slidingExpirationMinutes: 120)] 10 | public sealed record GetAllPromptCommentsQuery : IRequest>, ICacheable, IPaginated 11 | { 12 | public string CacheGroup => "PromptComments"; 13 | 14 | [CacheKeyPart] 15 | public Guid PromptId { get; set; } 16 | [CacheKeyPart] 17 | public int PageNumber { get; set; } 18 | [CacheKeyPart] 19 | public int PageSize { get; set; } 20 | 21 | public GetAllPromptCommentsQuery(Guid promptId, int pageNumber = 1, int pageSize = 10) 22 | { 23 | PromptId = promptId; 24 | PageNumber = pageNumber; 25 | PageSize = pageSize; 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/PromptComments/Queries/GetAll/GetAllPromptCommentsQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using AkilliPrompt.WebApi.Models; 3 | using MediatR; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace AkilliPrompt.WebApi.V1.PromptComments.Queries.GetAll; 7 | 8 | public sealed class GetAllPromptCommentsQueryHandler : IRequestHandler> 9 | { 10 | private readonly ApplicationDbContext _context; 11 | 12 | public GetAllPromptCommentsQueryHandler(ApplicationDbContext context) 13 | { 14 | _context = context; 15 | } 16 | 17 | public async Task> Handle(GetAllPromptCommentsQuery request, CancellationToken cancellationToken) 18 | { 19 | var query = _context.PromptComments.AsQueryable(); 20 | 21 | query = query.Where(pc => pc.PromptId == request.PromptId); 22 | 23 | var totalCount = await query.CountAsync(cancellationToken); 24 | 25 | query = query.OrderByDescending(pc => pc.CreatedAt); 26 | 27 | query = query.Include(pc => pc.User); 28 | 29 | query = query.Include(pc => pc.ParentComment); 30 | 31 | query = query.Include(pc => pc.ChildComments); 32 | 33 | var items = await query 34 | .AsNoTracking() 35 | .Skip((request.PageNumber - 1) * request.PageSize) 36 | .Take(request.PageSize) 37 | .AsSplitQuery() 38 | .ToListAsync(cancellationToken); 39 | 40 | var dtos = items.Select(pc => new GetAllPromptCommentsDto(pc)).ToList(); 41 | 42 | return PaginatedList.Create(dtos, totalCount, request.PageNumber, request.PageSize); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/PromptComments/Queries/GetAll/GetAllPromptCommentsQueryValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using AkilliPrompt.WebApi.Services; 3 | using FluentValidation; 4 | 5 | namespace AkilliPrompt.WebApi.V1.PromptComments.Queries.GetAll; 6 | 7 | public class GetAllPromptCommentsQueryValidator : AbstractValidator 8 | { 9 | private readonly IExistenceService _existenceService; 10 | 11 | public GetAllPromptCommentsQueryValidator(IExistenceService existenceService) 12 | { 13 | _existenceService = existenceService; 14 | 15 | RuleFor(e => e.PageNumber) 16 | .InclusiveBetween(1, 50) 17 | .WithMessage("Lütfen 1 ile 50 arasında bir sayfa numarası seçiniz."); 18 | 19 | RuleFor(e => e.PageSize) 20 | .InclusiveBetween(1, 50) 21 | .WithMessage("Lütfen 1 ile 50 arasında bir sayfa boyutu seçiniz."); 22 | 23 | RuleFor(e => e.PromptId) 24 | .NotEmpty() 25 | .WithMessage("Lütfen geçerli bir prompt seçiniz.") 26 | .MustAsync(PromptExists) 27 | .WithMessage("Belirtilen prompt mevcut değil."); 28 | } 29 | 30 | private Task PromptExists(Guid promptId, CancellationToken cancellationToken) 31 | { 32 | return _existenceService.ExistsAsync(promptId, cancellationToken); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Commands/Create/CreatePromptCommand.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Models; 2 | using MediatR; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Prompts.Commands.Create; 5 | 6 | public sealed class CreatePromptCommand : IRequest> 7 | { 8 | public string Title { get; set; } 9 | public string Description { get; set; } 10 | public string Content { get; set; } 11 | public IFormFile? Image { get; set; } 12 | public bool IsActive { get; set; } 13 | public List CategoryIds { get; set; } 14 | public List PlaceholderNames { get; set; } 15 | 16 | public CreatePromptCommand(string title, string description, string content, IFormFile? image, bool isActive, List categoryIds, List placeholderNames) 17 | { 18 | Title = title; 19 | Description = description; 20 | Content = content; 21 | Image = image; 22 | IsActive = isActive; 23 | CategoryIds = categoryIds ?? []; 24 | PlaceholderNames = placeholderNames ?? []; 25 | } 26 | 27 | public CreatePromptCommand() 28 | { 29 | 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Commands/Create/CreatePromptCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 3 | using AkilliPrompt.Persistence.Services; 4 | using AkilliPrompt.WebApi.Helpers; 5 | using AkilliPrompt.WebApi.Models; 6 | using AkilliPrompt.WebApi.Services; 7 | using MediatR; 8 | 9 | namespace AkilliPrompt.WebApi.V1.Prompts.Commands.Create; 10 | 11 | public sealed class CreatePromptCommandHandler : IRequestHandler> 12 | { 13 | private readonly ApplicationDbContext _context; 14 | private readonly CacheInvalidator _cacheInvalidator; 15 | private readonly R2ObjectStorageManager _r2ObjectStorageManager; 16 | private readonly ICurrentUserService _currentUserService; 17 | 18 | public CreatePromptCommandHandler(ApplicationDbContext context, CacheInvalidator cacheInvalidator, R2ObjectStorageManager r2ObjectStorageManager, ICurrentUserService currentUserService) 19 | { 20 | _context = context; 21 | _cacheInvalidator = cacheInvalidator; 22 | _r2ObjectStorageManager = r2ObjectStorageManager; 23 | _currentUserService = currentUserService; 24 | } 25 | 26 | 27 | public async Task> Handle(CreatePromptCommand request, CancellationToken cancellationToken) 28 | { 29 | var prompt = Prompt.Create(request.Title, request.Description, request.Content, request.IsActive, _currentUserService.UserId); 30 | 31 | if (request.Image is not null) 32 | { 33 | var imageUrl = await _r2ObjectStorageManager.UploadPromptPicAsync(request.Image, cancellationToken); 34 | 35 | prompt.SetImageUrl(imageUrl); 36 | } 37 | 38 | if (request.CategoryIds.Any()) 39 | { 40 | foreach (var categoryId in request.CategoryIds) 41 | { 42 | var promptCategory = PromptCategory.Create(prompt.Id, categoryId); 43 | 44 | _context.PromptCategories.Add(promptCategory); 45 | } 46 | } 47 | 48 | if (request.PlaceholderNames.Any()) 49 | { 50 | foreach (var placeholderName in request.PlaceholderNames) 51 | { 52 | var placeholder = Placeholder.Create(placeholderName, prompt.Id); 53 | 54 | _context.Placeholders.Add(placeholder); 55 | } 56 | } 57 | 58 | _context.Prompts.Add(prompt); 59 | 60 | await _context.SaveChangesAsync(cancellationToken); 61 | 62 | await _cacheInvalidator.InvalidateGroupAsync("Prompts", cancellationToken); 63 | 64 | return ResponseDto.Success(prompt.Id, MessageHelper.GetApiSuccessCreatedMessage("Prompt")); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Commands/Create/CreatePromptCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using FluentValidation; 3 | using Microsoft.EntityFrameworkCore; 4 | using System.IO; 5 | 6 | namespace AkilliPrompt.WebApi.V1.Prompts.Commands.Create; 7 | 8 | public sealed class CreatePromptCommandValidator : AbstractValidator 9 | { 10 | private readonly ApplicationDbContext _context; 11 | 12 | public CreatePromptCommandValidator(ApplicationDbContext context) 13 | { 14 | _context = context; 15 | 16 | RuleFor(x => x.Title) 17 | .NotEmpty() 18 | .WithMessage("Başlık alanı boş bırakılamaz.") 19 | .MaximumLength(200) 20 | .WithMessage("Başlık alanı en fazla {1} karakter olabilir.") 21 | .MinimumLength(3) 22 | .WithMessage("Başlık alanı en az {1} karakter olmalıdır."); 23 | 24 | RuleFor(x => x.Description) 25 | .NotEmpty() 26 | .WithMessage("Açıklama alanı boş bırakılamaz.") 27 | .MaximumLength(5000) 28 | .WithMessage("Açıklama alanı en fazla {1} karakter olabilir.") 29 | .MinimumLength(3) 30 | .WithMessage("Açıklama alanı en az {1} karakter olmalıdır."); 31 | 32 | RuleFor(x => x.Content) 33 | .NotEmpty() 34 | .WithMessage("İçerik alanı boş bırakılamaz.") 35 | .MaximumLength(50000) 36 | .WithMessage("İçerik alanı en fazla {1} karakter olabilir.") 37 | .MinimumLength(3) 38 | .WithMessage("İçerik alanı en az {1} karakter olmalıdır."); 39 | 40 | RuleFor(x => x.IsActive) 41 | .NotNull() 42 | .WithMessage("Durum alanı boş bırakılamaz."); 43 | 44 | RuleFor(x => x.CategoryIds) 45 | .NotEmpty() 46 | .WithMessage("En az bir kategori seçilmelidir.") 47 | .Must(CategoryIdsExist) 48 | .WithMessage("Seçilen kategori veya kategoriler bulunamadı."); 49 | 50 | RuleFor(x => x.Image) 51 | .Must(BeValidImage!) 52 | .When(x => x.Image is not null) 53 | .WithMessage("Resim dosyası geçerli bir resim dosyası olmalıdır."); 54 | } 55 | 56 | 57 | private bool BeValidImage(IFormFile image) 58 | { 59 | if (image == null) return false; 60 | 61 | var allowedExtensions = new[] { ".png", ".jpeg", ".jpg", ".webp" }; 62 | 63 | var extension = Path.GetExtension(image.FileName).ToLowerInvariant(); 64 | 65 | return allowedExtensions.Contains(extension); 66 | } 67 | 68 | private bool CategoryIdsExist(List categoryIds) 69 | { 70 | return _context.Categories.Any(x => categoryIds.Contains(x.Id)); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Commands/Delete/DeletePromptCommand.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.Models; 2 | using MediatR; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Prompts.Commands.Delete; 5 | 6 | public sealed record DeletePromptCommand(Guid Id) : IRequest>; 7 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Commands/Delete/DeletePromptCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 3 | using AkilliPrompt.WebApi.Helpers; 4 | using AkilliPrompt.WebApi.Models; 5 | using AkilliPrompt.WebApi.Services; 6 | using MediatR; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace AkilliPrompt.WebApi.V1.Prompts.Commands.Delete; 10 | 11 | public sealed class DeletePromptCommandHandler : IRequestHandler> 12 | { 13 | private readonly IExistenceService _existenceService; 14 | private readonly CacheInvalidator _cacheInvalidator; 15 | 16 | private readonly ApplicationDbContext _context; 17 | 18 | public DeletePromptCommandHandler( 19 | IExistenceService existenceService, 20 | CacheInvalidator cacheInvalidator, 21 | ApplicationDbContext context) 22 | { 23 | _existenceService = existenceService; 24 | _cacheInvalidator = cacheInvalidator; 25 | _context = context; 26 | } 27 | 28 | public async Task> Handle(DeletePromptCommand request, CancellationToken cancellationToken) 29 | { 30 | await _context 31 | .Prompts 32 | .Where(p => p.Id == request.Id) 33 | .ExecuteDeleteAsync(cancellationToken); 34 | 35 | await _cacheInvalidator.InvalidateGroupAsync("Prompts", cancellationToken); 36 | 37 | await _existenceService.RemoveExistenceAsync(request.Id, cancellationToken); 38 | 39 | return ResponseDto.Success(request.Id, MessageHelper.GetApiSuccessDeletedMessage(typeof(Prompt).Name)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Commands/Delete/DeletePromptCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Entities; 2 | using AkilliPrompt.WebApi.Common.FluentValidation; 3 | using AkilliPrompt.WebApi.Services; 4 | using FluentValidation; 5 | 6 | namespace AkilliPrompt.WebApi.V1.Prompts.Commands.Delete; 7 | 8 | public sealed class DeletePromptCommandValidator : EntityExistsValidator 9 | { 10 | 11 | public DeletePromptCommandValidator(IExistenceService existenceService) 12 | : base(existenceService) 13 | { 14 | RuleFor(p => p.Id) 15 | .NotEmpty() 16 | .WithMessage("Lütfen bir prompt seçiniz."); 17 | 18 | } 19 | 20 | protected override Guid GetEntityId(DeletePromptCommand command) => command.Id; 21 | } 22 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/PromptsController.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.WebApi.V1.Prompts.Commands.Create; 2 | using AkilliPrompt.WebApi.V1.Prompts.Commands.Delete; 3 | using AkilliPrompt.WebApi.V1.Prompts.Queries.GetAll; 4 | using AkilliPrompt.WebApi.V1.Prompts.Queries.GetById; 5 | using Asp.Versioning; 6 | using MediatR; 7 | using Microsoft.AspNetCore.Authorization; 8 | using Microsoft.AspNetCore.Mvc; 9 | 10 | namespace AkilliPrompt.WebApi.V1.Prompts; 11 | 12 | [ApiController] 13 | [ApiVersion("1.0")] 14 | [Route("v{version:apiVersion}/[controller]")] 15 | [Authorize] 16 | public sealed class PromptsController : ControllerBase 17 | { 18 | 19 | private readonly ISender _mediator; 20 | 21 | public PromptsController(ISender mediator) 22 | { 23 | _mediator = mediator; 24 | } 25 | 26 | [HttpPost("get-all")] 27 | [MapToApiVersion("1.0")] 28 | public async Task GetAllAsync(GetAllPromptsQuery query, CancellationToken cancellationToken = default) 29 | { 30 | return Ok(await _mediator.Send(query, cancellationToken)); 31 | } 32 | 33 | [HttpGet("{id:guid}")] 34 | [MapToApiVersion("1.0")] 35 | public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) 36 | { 37 | return Ok(await _mediator.Send(new GetPromptByIdQuery(id), cancellationToken)); 38 | } 39 | 40 | [HttpPost] 41 | [MapToApiVersion("1.0")] 42 | [Consumes("multipart/form-data")] 43 | public async Task CreateAsync([FromForm] CreatePromptCommand command, CancellationToken cancellationToken) 44 | { 45 | return Ok(await _mediator.Send(command, cancellationToken)); 46 | } 47 | 48 | [HttpDelete("{id:guid}")] 49 | public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) 50 | { 51 | return Ok(await _mediator.Send(new DeletePromptCommand(id), cancellationToken)); 52 | } 53 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetAll/GetAllPromptsDto.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.V1.Prompts.Queries.GetAll; 2 | 3 | public sealed record GetAllPromptsDto 4 | { 5 | public Guid Id { get; private set; } 6 | public string Title { get; private set; } 7 | public string Description { get; private set; } 8 | public string Content { get; private set; } 9 | public string? ImageUrl { get; private set; } 10 | 11 | private GetAllPromptsDto(Guid id, string title, string description, string content, string? imageUrl) 12 | { 13 | Id = id; 14 | Title = title; 15 | Description = description; 16 | Content = content; 17 | ImageUrl = imageUrl; 18 | } 19 | 20 | public static GetAllPromptsDto Create(Guid id, string title, string description, string content, string? imageUrl) 21 | { 22 | return new GetAllPromptsDto(id, title, description, content, imageUrl); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetAll/GetAllPromptsQuery.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using AkilliPrompt.WebApi.Attributes; 3 | using AkilliPrompt.WebApi.Interfaces; 4 | using AkilliPrompt.WebApi.Models; 5 | using MediatR; 6 | 7 | namespace AkilliPrompt.WebApi.V1.Prompts.Queries.GetAll; 8 | 9 | 10 | [CacheOptions(absoluteExpirationMinutes: 600, slidingExpirationMinutes: 120)] 11 | public sealed record GetAllPromptsQuery : IRequest>, ICacheable, IPaginated 12 | { 13 | public string CacheGroup => "Prompts"; 14 | 15 | [CacheKeyPart] 16 | public string? SearchKeyword { get; set; } = null; 17 | [CacheKeyPart] 18 | public List CategoryIds { get; set; } = []; 19 | [CacheKeyPart] 20 | public int PageNumber { get; } 21 | [CacheKeyPart] 22 | public int PageSize { get; } 23 | 24 | public GetAllPromptsQuery(int pageNumber, int pageSize, string? searchKeyword, List categoryIds) 25 | { 26 | PageNumber = pageNumber; 27 | PageSize = pageSize; 28 | SearchKeyword = searchKeyword; 29 | CategoryIds = categoryIds; 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetAll/GetAllPromptsQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using AkilliPrompt.WebApi.Models; 3 | using MediatR; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace AkilliPrompt.WebApi.V1.Prompts.Queries.GetAll; 7 | 8 | public sealed class GetAllPromptsQueryHandler : IRequestHandler> 9 | { 10 | private readonly ApplicationDbContext _context; 11 | 12 | public GetAllPromptsQueryHandler(ApplicationDbContext context) 13 | { 14 | _context = context; 15 | } 16 | 17 | public async Task> Handle(GetAllPromptsQuery request, CancellationToken cancellationToken) 18 | { 19 | // Base query with filters 20 | var query = _context.Prompts.AsQueryable(); 21 | 22 | if (!string.IsNullOrWhiteSpace(request.SearchKeyword)) 23 | query = query.Where(p => p.Title.Contains(request.SearchKeyword)); 24 | 25 | if (request.CategoryIds.Any()) 26 | query = query.Where(p => p.PromptCategories.Any(c => request.CategoryIds.Contains(c.CategoryId))); 27 | 28 | // Order by LikeCount descending 29 | query = query.OrderByDescending(p => p.LikeCount); 30 | 31 | // Get total count after filters but before pagination 32 | var totalCount = await query.CountAsync(cancellationToken); 33 | 34 | // Apply pagination 35 | var items = await query 36 | .AsNoTracking() 37 | .Skip((request.PageNumber - 1) * request.PageSize) 38 | .Take(request.PageSize) 39 | .Select(p => GetAllPromptsDto.Create( 40 | p.Id, 41 | p.Title, 42 | p.Description, 43 | p.Content, 44 | p.ImageUrl)) 45 | .ToListAsync(cancellationToken); 46 | 47 | return PaginatedList.Create(items, totalCount, request.PageNumber, request.PageSize); 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetAll/GetAllPromptsQueryValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FluentValidation; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Prompts.Queries.GetAll; 5 | 6 | public sealed class GetAllPromptsQueryValidator : AbstractValidator 7 | { 8 | public GetAllPromptsQueryValidator() 9 | { 10 | RuleFor(x => x.PageNumber) 11 | .GreaterThanOrEqualTo(1) 12 | .WithMessage("Sayfa numarası 1 veya daha büyük olmalıdır."); 13 | 14 | RuleFor(x => x.PageSize) 15 | .GreaterThan(0) 16 | .WithMessage("Sayfa boyutu 0'dan büyük olmalıdır."); 17 | 18 | RuleFor(x => x.SearchKeyword) 19 | .MaximumLength(100) 20 | .When(x => !string.IsNullOrEmpty(x.SearchKeyword)) 21 | .WithMessage("Arama anahtarı en fazla 100 karakter olabilir."); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetById/GetPromptByIdDto.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.V1.Prompts; 2 | 3 | public sealed record GetPromptByIdDto( 4 | Guid Id, 5 | string Title, 6 | string Description, 7 | string Content, 8 | string? ImageUrl, 9 | bool IsActive, 10 | IEnumerable Categories, 11 | IEnumerable Placeholders); 12 | 13 | public sealed record PromptCategoryDto(Guid Id, string Name); 14 | public sealed record PlaceholderDto(Guid Id, string Name); -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetById/GetPromptByIdQuery.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Domain.Common; 2 | using MediatR; 3 | 4 | namespace AkilliPrompt.WebApi.V1.Prompts.Queries.GetById; 5 | 6 | public sealed record GetPromptByIdQuery(Guid Id) : IRequest, ICacheable 7 | { 8 | public string CacheGroup => "Prompts"; 9 | } 10 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetById/GetPromptByIdQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using MediatR; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Prompts.Queries.GetById; 6 | 7 | public sealed class GetPromptByIdQueryHandler : IRequestHandler 8 | { 9 | private readonly ApplicationDbContext _context; 10 | public GetPromptByIdQueryHandler(ApplicationDbContext context) 11 | { 12 | _context = context; 13 | } 14 | public async Task Handle(GetPromptByIdQuery request, CancellationToken cancellationToken) 15 | { 16 | var prompt = await _context 17 | .Prompts 18 | .Include(x => x.PromptCategories) 19 | .ThenInclude(x => x.Category) 20 | .Include(x => x.Placeholders) 21 | .AsSplitQuery() 22 | .FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken); 23 | 24 | return new GetPromptByIdDto(prompt.Id, prompt.Title, prompt.Description, prompt.Content, prompt.ImageUrl, prompt.IsActive, prompt.PromptCategories.Select(c => new PromptCategoryDto(c.Id, c.Category.Name)), prompt.Placeholders.Select(ph => new PlaceholderDto(ph.Id, ph.Name))); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/Queries/GetById/GetPromptByIdQueryValidator.cs: -------------------------------------------------------------------------------- 1 | using AkilliPrompt.Persistence.EntityFramework.Contexts; 2 | using FluentValidation; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace AkilliPrompt.WebApi.V1.Prompts.Queries.GetById; 6 | 7 | public sealed class GetPromptByIdQueryValidator : AbstractValidator 8 | { 9 | private readonly ApplicationDbContext _context; 10 | public GetPromptByIdQueryValidator(ApplicationDbContext context) 11 | { 12 | _context = context; 13 | 14 | RuleFor(p => p.Id) 15 | .NotEmpty() 16 | .WithMessage("Lütfen bir prompt seçiniz.") 17 | .MustAsync(IsPromptExistsAsync) 18 | .WithMessage("Belirtilen id'ye sahip prompt bulunamadı."); 19 | } 20 | 21 | private Task IsPromptExistsAsync(Guid id, CancellationToken cancellationToken) 22 | { 23 | return _context.Prompts.AnyAsync(p => p.Id == id, cancellationToken); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/V1/Prompts/UpdatePromptDto.cs: -------------------------------------------------------------------------------- 1 | namespace AkilliPrompt.WebApi.V1.Prompts; 2 | 3 | public sealed record UpdatePromptDto( 4 | Guid Id, 5 | string Title, 6 | string Description, 7 | string Content, 8 | IFormFile? Image, 9 | bool IsActive, 10 | ICollection CategoryIds, 11 | ICollection PlaceholderNames); -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "ConnectionStrings": { 9 | "DefaultConnection": "Server=localhost;Port=1453;Database=AkilliPrompt;User Id=postgres;Password=123alperhocam123;" 10 | } 11 | } -------------------------------------------------------------------------------- /src/AkilliPrompt.WebApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "AzureKeyVaultSettings": { 10 | "Uri": "https://akilliprompt-kv-prod.vault.azure.net/", 11 | "TenantId": "", 12 | "ClientId": "", 13 | "ClientSecret": "" 14 | }, 15 | "ConnectionStrings": { 16 | "DefaultConnection": "Server=localhost;Port=1453;Database=AkilliPrompt;User Id=postgres;Password=123alperhocam123;", 17 | "Dragonfly": "localhost:6379" 18 | }, 19 | "CloudflareR2Settings": { 20 | "ServiceUrl": "https://r2.cloudflarestorage.com", 21 | "AccessKey": "", 22 | "SecretKey": "", 23 | "PromptPicsBucketName": "", 24 | "UserPicsBucketName": "" 25 | }, 26 | "JwtSettings": { 27 | "SecretKey": "S+iL^3