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 <api.Tests>" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
+ <Project Location="/home/jbourdon/repos/trakqr/src/api.Tests" Presentation="<api.Tests>" />
+</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 @@
+
+
+
+