diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5467af2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,107 @@ +# TrakQR Architecture Guidelines + +## Core Principles + +### 1. Modular Monolith +The application is structured as a modular monolith where each feature/domain is self-contained but runs in a single deployable unit. + +This provides: +- Clear boundaries between features +- Easy refactoring to microservices if needed later +- Simplified deployment and operations + +### 2. Vertical Slice Architecture +Code is organized by **feature** (vertical slices), not by technical layer (horizontal). + +Each feature contains everything it needs: +- Endpoint definitions +- Request/Response models +- Business logic +- Validators + +``` +/Features + /Auth + /Endpoints + SmallEndpoint.cs (contains Request, Response, Validator and Endpoint) + /Register + Endpoint.cs + Request.cs + Response.cs + Validator.cs + /Login + ... + /Links + /Create + ... + /List + ... + /QRCodes + ... +``` + +### 3. Minimal API with FastEndpoints +We use [FastEndpoints](https://fast-endpoints.com/) instead of traditional MVC Controllers because: +- Better performance (no reflection-based model binding) +- Cleaner, more focused endpoint classes +- Built-in validation with FluentValidation +- Request/Response DTOs are co-located with endpoints +- Easier testing + +### 4. No Traditional Controllers +**DO NOT** use `[ApiController]` or `ControllerBase`. All HTTP endpoints must be FastEndpoints. + +## Module Structure + +Each feature module should be **fully self-contained**. All code related to a feature lives within that feature's folder: + +``` +/Features/{Module}/ + - {Module}Responses.cs # Shared response DTOs for this module + - {Module}Settings.cs # Configuration classes for this module + /{Operation}/ + - Endpoint.cs # The FastEndpoint class + - Request.cs # Input DTO + - Validator.cs # FluentValidation rules +``` + +**Example - Auth module:** +``` +/Features/Auth/ + - AuthResponses.cs # AuthResponse, UserInfo, MessageResponse + - JwtSettings.cs # JWT configuration + /Register/ + - Endpoint.cs + - Request.cs + - Validator.cs + /Login/ + - Endpoint.cs + - Request.cs + - Validator.cs +``` + +For simple operations, business logic lives directly in the Endpoint class. For complex logic, add a `Handler.cs` or `Service.cs` within the same feature folder. + +## Shared Infrastructure + +Only truly cross-cutting infrastructure goes outside Features: +- `/Data` - DbContext, migrations (shared database access) +- `/Models` - EF Core entities (shared domain model) + +## Dependency Injection + +- Use constructor injection +- Register services in `Program.cs` or feature-specific extension methods +- Prefer scoped services for request-specific work + +## Validation + +- Use FluentValidation via FastEndpoints' built-in support +- Validate at the endpoint level, not in services +- Return 400 Bad Request with structured error responses + +## Authentication + +- JWT Bearer tokens for API authentication +- Claims-based authorization +- User ID extracted from JWT `sub` claim diff --git a/src/.idea/.idea.src/.idea/.gitignore b/src/.idea/.idea.src/.idea/.gitignore new file mode 100644 index 0000000..959a6f7 --- /dev/null +++ b/src/.idea/.idea.src/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/.idea.src.iml +/contentModel.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/.idea/.idea.src/.idea/encodings.xml b/src/.idea/.idea.src/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/src/.idea/.idea.src/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/.idea/.idea.src/.idea/indexLayout.xml b/src/.idea/.idea.src/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/src/.idea/.idea.src/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.src/.idea/vcs.xml b/src/.idea/.idea.src/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/src/.idea/.idea.src/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/api.Tests/.gitignore b/src/api.Tests/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/src/api.Tests/.gitignore @@ -0,0 +1,482 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/src/api.Tests/ApiWebApplicationFactory.cs b/src/api.Tests/ApiWebApplicationFactory.cs new file mode 100644 index 0000000..f0ecd61 --- /dev/null +++ b/src/api.Tests/ApiWebApplicationFactory.cs @@ -0,0 +1,50 @@ +using api.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.PostgreSql; + +namespace Api.Tests; + +public sealed class ApiWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest") + .Build(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + // Remove existing DbContext registration + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + // Add DbContext with Testcontainers connection string + services.AddDbContext(options => + options.UseNpgsql(_postgres.GetConnectionString())); + }); + } + + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + + // Run migrations + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + public new async Task DisposeAsync() + { + await _postgres.DisposeAsync(); + await base.DisposeAsync(); + } +} diff --git a/src/api.Tests/AuthControllerTests.cs b/src/api.Tests/AuthControllerTests.cs new file mode 100644 index 0000000..c439360 --- /dev/null +++ b/src/api.Tests/AuthControllerTests.cs @@ -0,0 +1,225 @@ +using System.Net; +using System.Net.Http.Json; +using api.Features.Auth.Common; +using FluentAssertions; + +namespace Api.Tests; + +public class AuthControllerTests(ApiWebApplicationFactory factory) + : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + [Fact] + public async Task Register_WithValidCredentials_ReturnsTokenAndUser() + { + // Arrange + var request = new { Email = "newuser@example.com", Password = "password123" }; + + // Act + var response = await _client.PostAsJsonAsync("/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Token.Should().NotBeNullOrEmpty(); + result.User.Email.Should().Be("newuser@example.com"); + result.User.IsVerified.Should().BeFalse(); + result.ExpiresAt.Should().BeAfter(DateTime.UtcNow); + } + + [Fact] + public async Task Register_WithDuplicateEmail_ReturnsConflict() + { + // Arrange + var request = new { Email = "duplicate@example.com", Password = "password123" }; + + // First registration + await _client.PostAsJsonAsync("/auth/register", request); + + // Act - try to register again + var response = await _client.PostAsJsonAsync("/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Message.Should().Be("Email already registered"); + } + + [Fact] + public async Task Register_WithInvalidEmail_ReturnsBadRequest() + { + // Arrange + var request = new { Email = "not-an-email", Password = "password123" }; + + // Act + var response = await _client.PostAsJsonAsync("/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Register_WithShortPassword_ReturnsBadRequest() + { + // Arrange + var request = new { Email = "shortpw@example.com", Password = "short" }; + + // Act + var response = await _client.PostAsJsonAsync("/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Login_WithValidCredentials_ReturnsToken() + { + // Arrange + var email = "logintest@example.com"; + var password = "password123"; + + await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = password }); + + // Act + var response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = password }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Token.Should().NotBeNullOrEmpty(); + result.User.Email.Should().Be(email); + } + + [Fact] + public async Task Login_WithWrongPassword_ReturnsUnauthorized() + { + // Arrange + var email = "wrongpw@example.com"; + await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "correctpassword" }); + + // Act + var response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "wrongpassword" }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Message.Should().Be("Invalid email or password"); + } + + [Fact] + public async Task Login_WithNonExistentEmail_ReturnsUnauthorized() + { + // Arrange + var request = new { Email = "nonexistent@example.com", Password = "password123" }; + + // Act + var response = await _client.PostAsJsonAsync("/auth/login", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Message.Should().Be("Invalid email or password"); + } + + [Fact] + public async Task ForgotPassword_WithAnyEmail_ReturnsSuccessMessage() + { + // Arrange - using a non-existent email to verify we don't leak info + var request = new { Email = "anyone@example.com" }; + + // Act + var response = await _client.PostAsJsonAsync("/auth/forgot", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Message.Should().Be("If the email exists, a reset link will be sent"); + } + + [Fact] + public async Task ForgotPassword_WithExistingEmail_ReturnsSuccessMessage() + { + // Arrange + var email = "forgotpw@example.com"; + await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" }); + + // Act + var response = await _client.PostAsJsonAsync("/auth/forgot", new { Email = email }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Message.Should().Be("If the email exists, a reset link will be sent"); + } + + [Fact] + public async Task ResetPassword_ReturnsNotImplemented() + { + // Arrange + var request = new { Token = "some-token", NewPassword = "newpassword123" }; + + // Act + var response = await _client.PostAsJsonAsync("/auth/reset", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Message.Should().Be("Password reset is not yet available"); + } + + [Fact] + public async Task Register_CreatesDefaultWorkspace() + { + // Arrange + var request = new { Email = "workspace@example.com", Password = "password123" }; + + // Act + var response = await _client.PostAsJsonAsync("/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.User.Id.Should().NotBeEmpty(); + // The workspace is created but not returned in auth response + // This could be verified with a separate workspaces endpoint test + } + + [Fact] + public async Task Login_IsCaseInsensitive() + { + // Arrange + var email = "CaseTEST@Example.COM"; + var password = "password123"; + + await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = password }); + + // Act - login with different casing + var response = await _client.PostAsJsonAsync("/auth/login", + new { Email = "casetest@example.com", Password = password }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result!.User.Email.Should().Be("casetest@example.com"); + } +} diff --git a/src/api.Tests/api.Tests.csproj b/src/api.Tests/api.Tests.csproj new file mode 100644 index 0000000..33d3b9d --- /dev/null +++ b/src/api.Tests/api.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/api/Data/AppDbContext.cs b/src/api/Data/AppDbContext.cs index 54dd506..b0911d2 100644 --- a/src/api/Data/AppDbContext.cs +++ b/src/api/Data/AppDbContext.cs @@ -1,20 +1,17 @@ -using Api.Models; +using api.Models; using Microsoft.EntityFrameworkCore; -namespace Api.Data; +namespace api.Data; -public class AppDbContext : DbContext +public class AppDbContext(DbContextOptions options) + : DbContext(options) { - public AppDbContext(DbContextOptions options) : base(options) - { - } - public DbSet Users => Set(); public DbSet Workspaces => Set(); public DbSet Projects => Set(); public DbSet Domains => Set(); public DbSet ShortLinks => Set(); - public DbSet QRCodeDesigns => Set(); + public DbSet QrCodeDesigns => Set(); public DbSet Events => Set(); public DbSet Assets => Set(); diff --git a/src/api/Features/Auth/Common/AuthResponses.cs b/src/api/Features/Auth/Common/AuthResponses.cs new file mode 100644 index 0000000..bacf3db --- /dev/null +++ b/src/api/Features/Auth/Common/AuthResponses.cs @@ -0,0 +1,15 @@ +namespace api.Features.Auth.Common; + +public record AuthResponse( + string Token, + DateTime ExpiresAt, + UserInfo User +); + +public record UserInfo( + Guid Id, + string Email, + bool IsVerified +); + +public record MessageResponse(string Message); diff --git a/src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs b/src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs new file mode 100644 index 0000000..12c53a6 --- /dev/null +++ b/src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Auth.Endpoints; + +public class ForgotPasswordRequest +{ + public string Email { get; set; } = string.Empty; +} + +public class ForgotPasswordValidator : Validator +{ + public ForgotPasswordValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + } +} + +public class ForgotPasswordEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Post("/auth/forgot"); + AllowAnonymous(); + } + + public override async Task HandleAsync(ForgotPasswordRequest req, CancellationToken ct) + { + var normalizedEmail = req.Email.ToLowerInvariant(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct); + + if (user == null) + { + Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail); + } + else + { + var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .Replace("+", "-").Replace("/", "_").TrimEnd('='); + // TODO: Store reset token in database with expiration + // TODO: Send email with reset link + Logger.LogInformation("Password reset token generated for: {Email}, Token: {Token}", normalizedEmail, resetToken); + } + + // Always return success to prevent email enumeration + await HttpContext.Response.SendAsync(new MessageResponse("If the email exists, a reset link will be sent"), 200, cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/LoginEndpoint.cs b/src/api/Features/Auth/Endpoints/LoginEndpoint.cs new file mode 100644 index 0000000..a4ba6a7 --- /dev/null +++ b/src/api/Features/Auth/Endpoints/LoginEndpoint.cs @@ -0,0 +1,85 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Auth.Settings; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace api.Features.Auth.Endpoints; + +public class LoginRequest +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public class LoginValidator : Validator +{ + public LoginValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required"); + } +} + +public class LoginEndpoint(AppDbContext db, IOptions jwtSettings) + : Endpoint +{ + private readonly JwtSettings _jwtSettings = jwtSettings.Value; + + public override void Configure() + { + Post("/auth/login"); + AllowAnonymous(); + } + + public override async Task HandleAsync(LoginRequest req, CancellationToken ct) + { + var normalizedEmail = req.Email.ToLowerInvariant(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct); + + if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) + { + await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401, cancellation: ct); + return; + } + + Logger.LogInformation("User logged in: {Email}", normalizedEmail); + + var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var token = new JwtSecurityToken( + issuer: _jwtSettings.Issuer, + audience: _jwtSettings.Audience, + claims: claims, + expires: expiresAt, + signingCredentials: credentials + ); + + var response = new AuthResponse( + Token: new JwtSecurityTokenHandler().WriteToken(token), + ExpiresAt: expiresAt, + User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue) + ); + + await HttpContext.Response.SendAsync(response, 200, cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/RegisterEndpoint.cs b/src/api/Features/Auth/Endpoints/RegisterEndpoint.cs new file mode 100644 index 0000000..650ad16 --- /dev/null +++ b/src/api/Features/Auth/Endpoints/RegisterEndpoint.cs @@ -0,0 +1,114 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Auth.Settings; +using api.Models; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace api.Features.Auth.Endpoints; + +public class RegisterRequest +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public class RegisterValidator : Validator +{ + public RegisterValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format") + .MaximumLength(255).WithMessage("Email must not exceed 255 characters"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required") + .MinimumLength(8).WithMessage("Password must be at least 8 characters") + .MaximumLength(100).WithMessage("Password must not exceed 100 characters"); + } +} + +public class RegisterEndpoint(AppDbContext db, IOptions jwtSettings) + : Endpoint +{ + private readonly JwtSettings _jwtSettings = jwtSettings.Value; + + public override void Configure() + { + Post("/auth/register"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RegisterRequest req, CancellationToken ct) + { + var normalizedEmail = req.Email.ToLowerInvariant(); + + if (await db.Users.AnyAsync(u => u.Email == normalizedEmail, ct)) + { + await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409, cancellation: ct); + return; + } + + var user = new User + { + Id = Guid.NewGuid(), + Email = normalizedEmail, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password), + CreatedAt = DateTime.UtcNow + }; + + db.Users.Add(user); + + var workspace = new Workspace + { + Id = Guid.NewGuid(), + OwnerUserId = user.Id, + Name = "My Workspace", + Plan = WorkspacePlan.Free, + CreatedAt = DateTime.UtcNow + }; + + db.Workspaces.Add(workspace); + await db.SaveChangesAsync(ct); + + Logger.LogInformation("User registered: {Email}", normalizedEmail); + + var response = GenerateAuthResponse(user); + await HttpContext.Response.SendAsync(response, 201, cancellation: ct); + } + + private AuthResponse GenerateAuthResponse(User user) + { + var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var token = new JwtSecurityToken( + issuer: _jwtSettings.Issuer, + audience: _jwtSettings.Audience, + claims: claims, + expires: expiresAt, + signingCredentials: credentials + ); + + return new AuthResponse( + Token: new JwtSecurityTokenHandler().WriteToken(token), + ExpiresAt: expiresAt, + User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue) + ); + } +} diff --git a/src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs b/src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs new file mode 100644 index 0000000..fe1fe07 --- /dev/null +++ b/src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs @@ -0,0 +1,46 @@ +using api.Features.Auth.Common; +using FastEndpoints; +using FluentValidation; + +namespace api.Features.Auth.Endpoints; + +public class ResetPasswordRequest +{ + public string Token { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public class ValidatorResetPassword : Validator +{ + public ValidatorResetPassword() + { + RuleFor(x => x.Token) + .NotEmpty().WithMessage("Token is required"); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("New password is required") + .MinimumLength(8).WithMessage("Password must be at least 8 characters") + .MaximumLength(100).WithMessage("Password must not exceed 100 characters"); + } +} + +public class ResetPasswordEndpoint : Endpoint +{ + public override void Configure() + { + Post("/auth/reset"); + AllowAnonymous(); + } + + public override async Task HandleAsync(ResetPasswordRequest req, CancellationToken ct) + { + // TODO: Implement password reset + // 1. Look up token in database + // 2. Verify token hasn't expired + // 3. Get associated user + // 4. Update password + // 5. Invalidate token + + await HttpContext.Response.SendAsync(new MessageResponse("Password reset is not yet available"), 400, cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Settings/JwtSettings.cs b/src/api/Features/Auth/Settings/JwtSettings.cs new file mode 100644 index 0000000..7521139 --- /dev/null +++ b/src/api/Features/Auth/Settings/JwtSettings.cs @@ -0,0 +1,9 @@ +namespace api.Features.Auth.Settings; + +public class JwtSettings +{ + public required string Secret { get; set; } + public required string Issuer { get; set; } + public required string Audience { get; set; } + public int ExpirationMinutes { get; set; } = 60; +} diff --git a/src/api/Migrations/20260127192536_InitialCreate.Designer.cs b/src/api/Migrations/20260127192536_InitialCreate.Designer.cs index ad19fcf..0bbc4f0 100644 --- a/src/api/Migrations/20260127192536_InitialCreate.Designer.cs +++ b/src/api/Migrations/20260127192536_InitialCreate.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Api.Data; +using api.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/src/api/Migrations/20260127193159_AddShortLinksQRCodesEventsAssets.Designer.cs b/src/api/Migrations/20260127193159_AddShortLinksQRCodesEventsAssets.Designer.cs index 465e112..e5523a3 100644 --- a/src/api/Migrations/20260127193159_AddShortLinksQRCodesEventsAssets.Designer.cs +++ b/src/api/Migrations/20260127193159_AddShortLinksQRCodesEventsAssets.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Api.Data; +using api.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/src/api/Migrations/20260127205418_RefactorAuth.Designer.cs b/src/api/Migrations/20260127205418_RefactorAuth.Designer.cs new file mode 100644 index 0000000..f5f2651 --- /dev/null +++ b/src/api/Migrations/20260127205418_RefactorAuth.Designer.cs @@ -0,0 +1,540 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using api.Data; + +#nullable disable + +namespace api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260127205418_refactor auth")] + partial class RefactorAuth + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("api.Models.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Mime") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VerificationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Hostname") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Domains"); + }); + + modelBuilder.Entity("api.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("DedupeKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DeviceType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IpHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QRCodeId") + .HasColumnType("uuid"); + + b.Property("Referrer") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ShortLinkId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("QRCodeId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("ShortLinkId", "Timestamp"); + + b.HasIndex("WorkspaceId", "Timestamp"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LogoAssetId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ShortLinkId") + .HasColumnType("uuid"); + + b.Property("StyleJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("LogoAssetId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ShortLinkId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("QrCodeDesigns"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DestinationUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DomainId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("DomainId", "Slug") + .IsUnique(); + + b.ToTable("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Workspaces"); + }); + + modelBuilder.Entity("api.Models.Asset", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Assets") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Domains") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Event", b => + { + b.HasOne("api.Models.QRCodeDesign", "QRCode") + .WithMany("Events") + .HasForeignKey("QRCodeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.ShortLink", "ShortLink") + .WithMany("Events") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Events") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("QRCode"); + + b.Navigation("ShortLink"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Projects") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.HasOne("api.Models.Asset", "LogoAsset") + .WithMany() + .HasForeignKey("LogoAssetId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Project", "Project") + .WithMany("QRCodeDesigns") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.ShortLink", "ShortLink") + .WithMany("QRCodeDesigns") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("QRCodeDesigns") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LogoAsset"); + + b.Navigation("Project"); + + b.Navigation("ShortLink"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.HasOne("api.Models.Domain", "Domain") + .WithMany("ShortLinks") + .HasForeignKey("DomainId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Project", "Project") + .WithMany("ShortLinks") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("ShortLinks") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Domain"); + + b.Navigation("Project"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.HasOne("api.Models.User", "Owner") + .WithMany("Workspaces") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.Navigation("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.Navigation("QRCodeDesigns"); + + b.Navigation("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.Navigation("Events"); + + b.Navigation("QRCodeDesigns"); + }); + + modelBuilder.Entity("api.Models.User", b => + { + b.Navigation("Workspaces"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.Navigation("Assets"); + + b.Navigation("Domains"); + + b.Navigation("Events"); + + b.Navigation("Projects"); + + b.Navigation("QRCodeDesigns"); + + b.Navigation("ShortLinks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/api/Migrations/20260127205418_RefactorAuth.cs b/src/api/Migrations/20260127205418_RefactorAuth.cs new file mode 100644 index 0000000..6d8d6c7 --- /dev/null +++ b/src/api/Migrations/20260127205418_RefactorAuth.cs @@ -0,0 +1,204 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace api.Migrations +{ + /// + public partial class RefactorAuth : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Events_QRCodeDesigns_QRCodeId", + table: "Events"); + + migrationBuilder.DropForeignKey( + name: "FK_QRCodeDesigns_Assets_LogoAssetId", + table: "QRCodeDesigns"); + + migrationBuilder.DropForeignKey( + name: "FK_QRCodeDesigns_Projects_ProjectId", + table: "QRCodeDesigns"); + + migrationBuilder.DropForeignKey( + name: "FK_QRCodeDesigns_ShortLinks_ShortLinkId", + table: "QRCodeDesigns"); + + migrationBuilder.DropForeignKey( + name: "FK_QRCodeDesigns_Workspaces_WorkspaceId", + table: "QRCodeDesigns"); + + migrationBuilder.DropPrimaryKey( + name: "PK_QRCodeDesigns", + table: "QRCodeDesigns"); + + migrationBuilder.RenameTable( + name: "QRCodeDesigns", + newName: "QrCodeDesigns"); + + migrationBuilder.RenameIndex( + name: "IX_QRCodeDesigns_WorkspaceId", + table: "QrCodeDesigns", + newName: "IX_QrCodeDesigns_WorkspaceId"); + + migrationBuilder.RenameIndex( + name: "IX_QRCodeDesigns_ShortLinkId", + table: "QrCodeDesigns", + newName: "IX_QrCodeDesigns_ShortLinkId"); + + migrationBuilder.RenameIndex( + name: "IX_QRCodeDesigns_ProjectId", + table: "QrCodeDesigns", + newName: "IX_QrCodeDesigns_ProjectId"); + + migrationBuilder.RenameIndex( + name: "IX_QRCodeDesigns_LogoAssetId", + table: "QrCodeDesigns", + newName: "IX_QrCodeDesigns_LogoAssetId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_QrCodeDesigns", + table: "QrCodeDesigns", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Events_QrCodeDesigns_QRCodeId", + table: "Events", + column: "QRCodeId", + principalTable: "QrCodeDesigns", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QrCodeDesigns_Assets_LogoAssetId", + table: "QrCodeDesigns", + column: "LogoAssetId", + principalTable: "Assets", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QrCodeDesigns_Projects_ProjectId", + table: "QrCodeDesigns", + column: "ProjectId", + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QrCodeDesigns_ShortLinks_ShortLinkId", + table: "QrCodeDesigns", + column: "ShortLinkId", + principalTable: "ShortLinks", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QrCodeDesigns_Workspaces_WorkspaceId", + table: "QrCodeDesigns", + column: "WorkspaceId", + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Events_QrCodeDesigns_QRCodeId", + table: "Events"); + + migrationBuilder.DropForeignKey( + name: "FK_QrCodeDesigns_Assets_LogoAssetId", + table: "QrCodeDesigns"); + + migrationBuilder.DropForeignKey( + name: "FK_QrCodeDesigns_Projects_ProjectId", + table: "QrCodeDesigns"); + + migrationBuilder.DropForeignKey( + name: "FK_QrCodeDesigns_ShortLinks_ShortLinkId", + table: "QrCodeDesigns"); + + migrationBuilder.DropForeignKey( + name: "FK_QrCodeDesigns_Workspaces_WorkspaceId", + table: "QrCodeDesigns"); + + migrationBuilder.DropPrimaryKey( + name: "PK_QrCodeDesigns", + table: "QrCodeDesigns"); + + migrationBuilder.RenameTable( + name: "QrCodeDesigns", + newName: "QRCodeDesigns"); + + migrationBuilder.RenameIndex( + name: "IX_QrCodeDesigns_WorkspaceId", + table: "QRCodeDesigns", + newName: "IX_QRCodeDesigns_WorkspaceId"); + + migrationBuilder.RenameIndex( + name: "IX_QrCodeDesigns_ShortLinkId", + table: "QRCodeDesigns", + newName: "IX_QRCodeDesigns_ShortLinkId"); + + migrationBuilder.RenameIndex( + name: "IX_QrCodeDesigns_ProjectId", + table: "QRCodeDesigns", + newName: "IX_QRCodeDesigns_ProjectId"); + + migrationBuilder.RenameIndex( + name: "IX_QrCodeDesigns_LogoAssetId", + table: "QRCodeDesigns", + newName: "IX_QRCodeDesigns_LogoAssetId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_QRCodeDesigns", + table: "QRCodeDesigns", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Events_QRCodeDesigns_QRCodeId", + table: "Events", + column: "QRCodeId", + principalTable: "QRCodeDesigns", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QRCodeDesigns_Assets_LogoAssetId", + table: "QRCodeDesigns", + column: "LogoAssetId", + principalTable: "Assets", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QRCodeDesigns_Projects_ProjectId", + table: "QRCodeDesigns", + column: "ProjectId", + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QRCodeDesigns_ShortLinks_ShortLinkId", + table: "QRCodeDesigns", + column: "ShortLinkId", + principalTable: "ShortLinks", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_QRCodeDesigns_Workspaces_WorkspaceId", + table: "QRCodeDesigns", + column: "WorkspaceId", + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/api/Migrations/AppDbContextModelSnapshot.cs b/src/api/Migrations/AppDbContextModelSnapshot.cs index 9e037a0..1f9c0ef 100644 --- a/src/api/Migrations/AppDbContextModelSnapshot.cs +++ b/src/api/Migrations/AppDbContextModelSnapshot.cs @@ -1,10 +1,10 @@ // using System; -using Api.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using api.Data; #nullable disable @@ -17,12 +17,12 @@ namespace api.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Api.Models.Asset", b => + modelBuilder.Entity("api.Models.Asset", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -61,7 +61,7 @@ namespace api.Migrations b.ToTable("Assets"); }); - modelBuilder.Entity("Api.Models.Domain", b => + modelBuilder.Entity("api.Models.Domain", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -100,7 +100,7 @@ namespace api.Migrations b.ToTable("Domains"); }); - modelBuilder.Entity("Api.Models.Event", b => + modelBuilder.Entity("api.Models.Event", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -164,7 +164,7 @@ namespace api.Migrations b.ToTable("Events"); }); - modelBuilder.Entity("Api.Models.Project", b => + modelBuilder.Entity("api.Models.Project", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -190,7 +190,7 @@ namespace api.Migrations b.ToTable("Projects"); }); - modelBuilder.Entity("Api.Models.QRCodeDesign", b => + modelBuilder.Entity("api.Models.QRCodeDesign", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -232,10 +232,10 @@ namespace api.Migrations b.HasIndex("WorkspaceId"); - b.ToTable("QRCodeDesigns"); + b.ToTable("QrCodeDesigns"); }); - modelBuilder.Entity("Api.Models.ShortLink", b => + modelBuilder.Entity("api.Models.ShortLink", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -298,7 +298,7 @@ namespace api.Migrations b.ToTable("ShortLinks"); }); - modelBuilder.Entity("Api.Models.User", b => + modelBuilder.Entity("api.Models.User", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -330,7 +330,7 @@ namespace api.Migrations b.ToTable("Users"); }); - modelBuilder.Entity("Api.Models.Workspace", b => + modelBuilder.Entity("api.Models.Workspace", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -361,9 +361,9 @@ namespace api.Migrations b.ToTable("Workspaces"); }); - modelBuilder.Entity("Api.Models.Asset", b => + modelBuilder.Entity("api.Models.Asset", b => { - b.HasOne("Api.Models.Workspace", "Workspace") + b.HasOne("api.Models.Workspace", "Workspace") .WithMany("Assets") .HasForeignKey("WorkspaceId") .OnDelete(DeleteBehavior.Cascade) @@ -372,9 +372,9 @@ namespace api.Migrations b.Navigation("Workspace"); }); - modelBuilder.Entity("Api.Models.Domain", b => + modelBuilder.Entity("api.Models.Domain", b => { - b.HasOne("Api.Models.Workspace", "Workspace") + b.HasOne("api.Models.Workspace", "Workspace") .WithMany("Domains") .HasForeignKey("WorkspaceId") .OnDelete(DeleteBehavior.Cascade) @@ -383,20 +383,20 @@ namespace api.Migrations b.Navigation("Workspace"); }); - modelBuilder.Entity("Api.Models.Event", b => + modelBuilder.Entity("api.Models.Event", b => { - b.HasOne("Api.Models.QRCodeDesign", "QRCode") + b.HasOne("api.Models.QRCodeDesign", "QRCode") .WithMany("Events") .HasForeignKey("QRCodeId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Api.Models.ShortLink", "ShortLink") + b.HasOne("api.Models.ShortLink", "ShortLink") .WithMany("Events") .HasForeignKey("ShortLinkId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Api.Models.Workspace", "Workspace") + b.HasOne("api.Models.Workspace", "Workspace") .WithMany("Events") .HasForeignKey("WorkspaceId") .OnDelete(DeleteBehavior.Cascade) @@ -409,9 +409,9 @@ namespace api.Migrations b.Navigation("Workspace"); }); - modelBuilder.Entity("Api.Models.Project", b => + modelBuilder.Entity("api.Models.Project", b => { - b.HasOne("Api.Models.Workspace", "Workspace") + b.HasOne("api.Models.Workspace", "Workspace") .WithMany("Projects") .HasForeignKey("WorkspaceId") .OnDelete(DeleteBehavior.Cascade) @@ -420,24 +420,24 @@ namespace api.Migrations b.Navigation("Workspace"); }); - modelBuilder.Entity("Api.Models.QRCodeDesign", b => + modelBuilder.Entity("api.Models.QRCodeDesign", b => { - b.HasOne("Api.Models.Asset", "LogoAsset") + b.HasOne("api.Models.Asset", "LogoAsset") .WithMany() .HasForeignKey("LogoAssetId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Api.Models.Project", "Project") + b.HasOne("api.Models.Project", "Project") .WithMany("QRCodeDesigns") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Api.Models.ShortLink", "ShortLink") + b.HasOne("api.Models.ShortLink", "ShortLink") .WithMany("QRCodeDesigns") .HasForeignKey("ShortLinkId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Api.Models.Workspace", "Workspace") + b.HasOne("api.Models.Workspace", "Workspace") .WithMany("QRCodeDesigns") .HasForeignKey("WorkspaceId") .OnDelete(DeleteBehavior.Cascade) @@ -452,19 +452,19 @@ namespace api.Migrations b.Navigation("Workspace"); }); - modelBuilder.Entity("Api.Models.ShortLink", b => + modelBuilder.Entity("api.Models.ShortLink", b => { - b.HasOne("Api.Models.Domain", "Domain") + b.HasOne("api.Models.Domain", "Domain") .WithMany("ShortLinks") .HasForeignKey("DomainId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Api.Models.Project", "Project") + b.HasOne("api.Models.Project", "Project") .WithMany("ShortLinks") .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("Api.Models.Workspace", "Workspace") + b.HasOne("api.Models.Workspace", "Workspace") .WithMany("ShortLinks") .HasForeignKey("WorkspaceId") .OnDelete(DeleteBehavior.Cascade) @@ -477,9 +477,9 @@ namespace api.Migrations b.Navigation("Workspace"); }); - modelBuilder.Entity("Api.Models.Workspace", b => + modelBuilder.Entity("api.Models.Workspace", b => { - b.HasOne("Api.Models.User", "Owner") + b.HasOne("api.Models.User", "Owner") .WithMany("Workspaces") .HasForeignKey("OwnerUserId") .OnDelete(DeleteBehavior.Cascade) @@ -488,36 +488,36 @@ namespace api.Migrations b.Navigation("Owner"); }); - modelBuilder.Entity("Api.Models.Domain", b => + modelBuilder.Entity("api.Models.Domain", b => { b.Navigation("ShortLinks"); }); - modelBuilder.Entity("Api.Models.Project", b => + modelBuilder.Entity("api.Models.Project", b => { b.Navigation("QRCodeDesigns"); b.Navigation("ShortLinks"); }); - modelBuilder.Entity("Api.Models.QRCodeDesign", b => + modelBuilder.Entity("api.Models.QRCodeDesign", b => { b.Navigation("Events"); }); - modelBuilder.Entity("Api.Models.ShortLink", b => + modelBuilder.Entity("api.Models.ShortLink", b => { b.Navigation("Events"); b.Navigation("QRCodeDesigns"); }); - modelBuilder.Entity("Api.Models.User", b => + modelBuilder.Entity("api.Models.User", b => { b.Navigation("Workspaces"); }); - modelBuilder.Entity("Api.Models.Workspace", b => + modelBuilder.Entity("api.Models.Workspace", b => { b.Navigation("Assets"); diff --git a/src/api/Models/Asset.cs b/src/api/Models/Asset.cs index b7dc7e9..bf1f8e1 100644 --- a/src/api/Models/Asset.cs +++ b/src/api/Models/Asset.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public enum AssetType { diff --git a/src/api/Models/Domain.cs b/src/api/Models/Domain.cs index e712045..ac3e401 100644 --- a/src/api/Models/Domain.cs +++ b/src/api/Models/Domain.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public enum DomainStatus { diff --git a/src/api/Models/Event.cs b/src/api/Models/Event.cs index abb9e25..6279f93 100644 --- a/src/api/Models/Event.cs +++ b/src/api/Models/Event.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public enum EventType { diff --git a/src/api/Models/Project.cs b/src/api/Models/Project.cs index 9745389..e5e3a51 100644 --- a/src/api/Models/Project.cs +++ b/src/api/Models/Project.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public class Project { diff --git a/src/api/Models/QRCodeDesign.cs b/src/api/Models/QRCodeDesign.cs index f4ccc7f..0ce56dc 100644 --- a/src/api/Models/QRCodeDesign.cs +++ b/src/api/Models/QRCodeDesign.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public class QRCodeDesign { diff --git a/src/api/Models/ShortLink.cs b/src/api/Models/ShortLink.cs index 80c064a..f98b622 100644 --- a/src/api/Models/ShortLink.cs +++ b/src/api/Models/ShortLink.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public enum ShortLinkStatus { diff --git a/src/api/Models/User.cs b/src/api/Models/User.cs index ed8e236..20bf140 100644 --- a/src/api/Models/User.cs +++ b/src/api/Models/User.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public class User { diff --git a/src/api/Models/Workspace.cs b/src/api/Models/Workspace.cs index e7bc33d..b17bdb0 100644 --- a/src/api/Models/Workspace.cs +++ b/src/api/Models/Workspace.cs @@ -1,4 +1,4 @@ -namespace Api.Models; +namespace api.Models; public enum WorkspacePlan { diff --git a/src/api/Program.cs b/src/api/Program.cs index 180276a..ababb78 100644 --- a/src/api/Program.cs +++ b/src/api/Program.cs @@ -1,13 +1,39 @@ -using Api.Data; +using System.Text; +using api.Data; +using api.Features.Auth.Settings; +using FastEndpoints; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); // Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"))); +// Configure JWT settings +builder.Services.Configure(builder.Configuration.GetSection("Jwt")); +var jwtSettings = builder.Configuration.GetSection("Jwt").Get()!; + +// Configure authentication +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)) + }; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddFastEndpoints(); builder.Services.AddOpenApi(); var app = builder.Build(); @@ -25,4 +51,9 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseFastEndpoints(); + app.Run(); diff --git a/src/api/api.csproj b/src/api/api.csproj index 8fa5b31..ebd4e58 100644 --- a/src/api/api.csproj +++ b/src/api/api.csproj @@ -7,8 +7,15 @@ + + + + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/api/appsettings.Development.json b/src/api/appsettings.Development.json index b629b7d..6503447 100644 --- a/src/api/appsettings.Development.json +++ b/src/api/appsettings.Development.json @@ -7,5 +7,8 @@ }, "ConnectionStrings": { "PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!" + }, + "Jwt": { + "Secret": "dev-secret-key-min-32-characters-long-for-hmac256!" } } diff --git a/src/api/appsettings.json b/src/api/appsettings.json index 94738ad..81b3581 100644 --- a/src/api/appsettings.json +++ b/src/api/appsettings.json @@ -8,5 +8,11 @@ "AllowedHosts": "*", "ConnectionStrings": { "PostgresConnection": "" + }, + "Jwt": { + "Secret": "", + "Issuer": "TrakQR", + "Audience": "TrakQR", + "ExpirationMinutes": 60 } } diff --git a/src/src.sln.DotSettings.user b/src/src.sln.DotSettings.user new file mode 100644 index 0000000..26a287d --- /dev/null +++ b/src/src.sln.DotSettings.user @@ -0,0 +1,5 @@ + + ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;api.Tests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="/home/jbourdon/repos/trakqr/src/api.Tests" Presentation="&lt;api.Tests&gt;" /> +</SessionState> \ No newline at end of file diff --git a/src/src.slnx b/src/src.slnx new file mode 100644 index 0000000..872e108 --- /dev/null +++ b/src/src.slnx @@ -0,0 +1,4 @@ + + + +