chore: correct namespaces and hiearchy
This commit is contained in:
482
src/TrackApi/TrackQrApi/.gitignore
vendored
Normal file
482
src/TrackApi/TrackQrApi/.gitignore
vendored
Normal file
@@ -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
|
||||
228
src/TrackApi/TrackQrApi/Data/AppDbContext.cs
Normal file
228
src/TrackApi/TrackQrApi/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Domain> Domains => Set<Domain>();
|
||||
public DbSet<ShortLink> ShortLinks => Set<ShortLink>();
|
||||
public DbSet<QRCodeDesign> QrCodeDesigns => Set<QRCodeDesign>();
|
||||
public DbSet<Event> Events => Set<Event>();
|
||||
public DbSet<Asset> Assets => Set<Asset>();
|
||||
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
|
||||
public DbSet<EmailVerificationToken> EmailVerificationTokens => Set<EmailVerificationToken>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// User configuration
|
||||
modelBuilder.Entity<User>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Email).IsUnique();
|
||||
entity.Property(e => e.Email).HasMaxLength(255);
|
||||
entity.Property(e => e.PasswordHash).HasMaxLength(255);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
});
|
||||
|
||||
// Workspace configuration
|
||||
modelBuilder.Entity<Workspace>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Name).HasMaxLength(100);
|
||||
entity.Property(e => e.Plan).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Owner)
|
||||
.WithMany(u => u.Workspaces)
|
||||
.HasForeignKey(e => e.OwnerUserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Project configuration
|
||||
modelBuilder.Entity<Project>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Name).HasMaxLength(100);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany(w => w.Projects)
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Domain configuration
|
||||
modelBuilder.Entity<Domain>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Hostname).IsUnique();
|
||||
entity.Property(e => e.Hostname).HasMaxLength(255);
|
||||
entity.Property(e => e.Status).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(e => e.VerificationToken).HasMaxLength(64);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany(w => w.Domains)
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ShortLink configuration
|
||||
modelBuilder.Entity<ShortLink>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.DomainId, e.Slug }).IsUnique();
|
||||
entity.Property(e => e.Slug).HasMaxLength(50);
|
||||
entity.Property(e => e.DestinationUrl).HasMaxLength(2048);
|
||||
entity.Property(e => e.Title).HasMaxLength(255);
|
||||
entity.Property(e => e.Status).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(e => e.PasswordHash).HasMaxLength(255);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany(w => w.ShortLinks)
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Project)
|
||||
.WithMany(p => p.ShortLinks)
|
||||
.HasForeignKey(e => e.ProjectId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(e => e.Domain)
|
||||
.WithMany(d => d.ShortLinks)
|
||||
.HasForeignKey(e => e.DomainId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// QRCodeDesign configuration
|
||||
modelBuilder.Entity<QRCodeDesign>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.StyleJson).HasColumnType("jsonb");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany(w => w.QRCodeDesigns)
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Project)
|
||||
.WithMany(p => p.QRCodeDesigns)
|
||||
.HasForeignKey(e => e.ProjectId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(e => e.ShortLink)
|
||||
.WithMany(s => s.QRCodeDesigns)
|
||||
.HasForeignKey(e => e.ShortLinkId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(e => e.LogoAsset)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.LogoAssetId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// Event configuration
|
||||
modelBuilder.Entity<Event>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Type).HasConversion<string>().HasMaxLength(10);
|
||||
entity.Property(e => e.Timestamp).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
entity.Property(e => e.IpHash).HasMaxLength(64);
|
||||
entity.Property(e => e.UserAgent).HasMaxLength(512);
|
||||
entity.Property(e => e.Referrer).HasMaxLength(2048);
|
||||
entity.Property(e => e.CountryCode).HasMaxLength(2);
|
||||
entity.Property(e => e.DeviceType).HasMaxLength(20);
|
||||
entity.Property(e => e.DedupeKey).HasMaxLength(128);
|
||||
|
||||
entity.HasIndex(e => e.Timestamp);
|
||||
entity.HasIndex(e => new { e.ShortLinkId, e.Timestamp });
|
||||
entity.HasIndex(e => new { e.WorkspaceId, e.Timestamp });
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany(w => w.Events)
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.ShortLink)
|
||||
.WithMany(s => s.Events)
|
||||
.HasForeignKey(e => e.ShortLinkId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.QRCode)
|
||||
.WithMany(q => q.Events)
|
||||
.HasForeignKey(e => e.QRCodeId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// Asset configuration
|
||||
modelBuilder.Entity<Asset>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Type).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(e => e.StorageKey).HasMaxLength(512);
|
||||
entity.Property(e => e.Mime).HasMaxLength(100);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany(w => w.Assets)
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// PasswordResetToken configuration
|
||||
modelBuilder.Entity<PasswordResetToken>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Token).IsUnique();
|
||||
entity.Property(e => e.Token).HasMaxLength(64);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// EmailVerificationToken configuration
|
||||
modelBuilder.Entity<EmailVerificationToken>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Token).IsUnique();
|
||||
entity.Property(e => e.Token).HasMaxLength(64);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ApiKey configuration
|
||||
modelBuilder.Entity<ApiKey>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.KeyHash).IsUnique();
|
||||
entity.Property(e => e.Name).HasMaxLength(100);
|
||||
entity.Property(e => e.KeyHash).HasMaxLength(64);
|
||||
entity.Property(e => e.KeyPrefix).HasMaxLength(16);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace TrackQrApi.Features.Analytics.Common;
|
||||
|
||||
public record AnalyticsSummary(
|
||||
int TotalClicks,
|
||||
int TotalScans,
|
||||
int UniqueVisitors,
|
||||
DateTime? FirstEvent,
|
||||
DateTime? LastEvent
|
||||
);
|
||||
|
||||
public record TimeSeriesPoint(
|
||||
DateTime Date,
|
||||
int Clicks,
|
||||
int Scans
|
||||
);
|
||||
|
||||
public record BreakdownItem(
|
||||
string Key,
|
||||
int Count,
|
||||
double Percentage
|
||||
);
|
||||
|
||||
public record WorkspaceAnalyticsResponse(
|
||||
AnalyticsSummary Summary,
|
||||
IEnumerable<TimeSeriesPoint> TimeSeries,
|
||||
IEnumerable<BreakdownItem> TopLinks,
|
||||
IEnumerable<BreakdownItem> DeviceBreakdown,
|
||||
IEnumerable<BreakdownItem> ReferrerBreakdown,
|
||||
IEnumerable<BreakdownItem> CountryBreakdown
|
||||
);
|
||||
|
||||
public record LinkAnalyticsResponse(
|
||||
Guid LinkId,
|
||||
string Slug,
|
||||
AnalyticsSummary Summary,
|
||||
IEnumerable<TimeSeriesPoint> TimeSeries,
|
||||
IEnumerable<BreakdownItem> DeviceBreakdown,
|
||||
IEnumerable<BreakdownItem> ReferrerBreakdown,
|
||||
IEnumerable<BreakdownItem> CountryBreakdown
|
||||
);
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Analytics.Common;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Analytics.Endpoints;
|
||||
|
||||
public class LinkAnalyticsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
|
||||
public DateTime? StartDate { get; set; } // Custom date range start
|
||||
public DateTime? EndDate { get; set; } // Custom date range end
|
||||
}
|
||||
|
||||
public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
: Endpoint<LinkAnalyticsRequest, LinkAnalyticsResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/links/{Id}/analytics");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(LinkAnalyticsRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Get link and verify ownership
|
||||
var link = await db.ShortLinks
|
||||
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId)
|
||||
.Select(l => new { l.Id, l.Slug })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine time filter (custom range takes precedence over period)
|
||||
DateTime? startDate = null;
|
||||
DateTime? endDate = null;
|
||||
|
||||
if (req.StartDate.HasValue && req.EndDate.HasValue)
|
||||
{
|
||||
startDate = req.StartDate.Value;
|
||||
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
|
||||
}
|
||||
else
|
||||
{
|
||||
startDate = GetStartDate(req.Period);
|
||||
}
|
||||
|
||||
// Query events for this link
|
||||
var eventsQuery = db.Events
|
||||
.Where(e => e.ShortLinkId == req.Id);
|
||||
|
||||
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
|
||||
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
|
||||
var events = await eventsQuery.ToListAsync(ct);
|
||||
var totalEvents = events.Count;
|
||||
|
||||
// Build summary
|
||||
var summary = new AnalyticsSummary(
|
||||
events.Count(e => e.Type == EventType.Click),
|
||||
events.Count(e => e.Type == EventType.Scan),
|
||||
events.Select(e => e.IpHash).Distinct().Count(),
|
||||
events.MinBy(e => e.Timestamp)?.Timestamp,
|
||||
events.MaxBy(e => e.Timestamp)?.Timestamp
|
||||
);
|
||||
|
||||
// Build time series
|
||||
var timeSeries = events
|
||||
.GroupBy(e => e.Timestamp.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new TimeSeriesPoint(
|
||||
g.Key,
|
||||
g.Count(e => e.Type == EventType.Click),
|
||||
g.Count(e => e.Type == EventType.Scan)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Device breakdown
|
||||
var deviceBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
|
||||
.GroupBy(e => e.DeviceType!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Referrer breakdown
|
||||
var referrerBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.Referrer))
|
||||
.GroupBy(e => ExtractDomain(e.Referrer!))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Country breakdown
|
||||
var countryBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
|
||||
.GroupBy(e => e.CountryCode!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var response = new LinkAnalyticsResponse(
|
||||
link.Id,
|
||||
link.Slug,
|
||||
summary,
|
||||
timeSeries,
|
||||
deviceBreakdown,
|
||||
referrerBreakdown,
|
||||
countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
|
||||
private static DateTime? GetStartDate(string? period)
|
||||
{
|
||||
return period?.ToLower() switch
|
||||
{
|
||||
"24h" => DateTime.UtcNow.AddHours(-24),
|
||||
"7d" => DateTime.UtcNow.AddDays(-7),
|
||||
"30d" => DateTime.UtcNow.AddDays(-30),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractDomain(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
return uri.Host;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return url.Length > 50 ? url[..50] : url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Analytics.Common;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Analytics.Endpoints;
|
||||
|
||||
public class WorkspaceAnalyticsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
|
||||
public DateTime? StartDate { get; set; } // Custom date range start
|
||||
public DateTime? EndDate { get; set; } // Custom date range end
|
||||
}
|
||||
|
||||
public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
: Endpoint<WorkspaceAnalyticsRequest, WorkspaceAnalyticsResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/analytics");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(WorkspaceAnalyticsRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine time filter (custom range takes precedence over period)
|
||||
DateTime? startDate = null;
|
||||
DateTime? endDate = null;
|
||||
|
||||
if (req.StartDate.HasValue && req.EndDate.HasValue)
|
||||
{
|
||||
startDate = req.StartDate.Value;
|
||||
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
|
||||
}
|
||||
else
|
||||
{
|
||||
startDate = GetStartDate(req.Period);
|
||||
}
|
||||
|
||||
// Query events
|
||||
var eventsQuery = db.Events
|
||||
.Where(e => e.WorkspaceId == req.WorkspaceId);
|
||||
|
||||
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
|
||||
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
|
||||
var events = await eventsQuery.ToListAsync(ct);
|
||||
var totalEvents = events.Count;
|
||||
|
||||
// Get summary
|
||||
var summary = new AnalyticsSummary(
|
||||
events.Count(e => e.Type == EventType.Click),
|
||||
events.Count(e => e.Type == EventType.Scan),
|
||||
events.Select(e => e.IpHash).Distinct().Count(),
|
||||
events.Count > 0 ? events.Min(e => e.Timestamp) : null,
|
||||
events.Count > 0 ? events.Max(e => e.Timestamp) : null
|
||||
);
|
||||
|
||||
// Get time series
|
||||
var timeSeries = events
|
||||
.GroupBy(e => e.Timestamp.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new TimeSeriesPoint(
|
||||
g.Key,
|
||||
g.Count(e => e.Type == EventType.Click),
|
||||
g.Count(e => e.Type == EventType.Scan)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Get top links - group events by link and get slugs
|
||||
var linkIds = events.Select(e => e.ShortLinkId).Distinct().ToList();
|
||||
var linkSlugs = await db.ShortLinks
|
||||
.Where(l => linkIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Slug })
|
||||
.ToDictionaryAsync(l => l.Id, l => l.Slug, ct);
|
||||
|
||||
var topLinks = events
|
||||
.GroupBy(e => e.ShortLinkId)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.Select(g => new BreakdownItem(
|
||||
linkSlugs.GetValueOrDefault(g.Key, "unknown"),
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Get device breakdown
|
||||
var deviceBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
|
||||
.GroupBy(e => e.DeviceType!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Get referrer breakdown
|
||||
var referrerBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.Referrer))
|
||||
.GroupBy(e => ExtractDomain(e.Referrer!))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Get country breakdown
|
||||
var countryBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
|
||||
.GroupBy(e => e.CountryCode!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var response = new WorkspaceAnalyticsResponse(
|
||||
summary,
|
||||
timeSeries,
|
||||
topLinks,
|
||||
deviceBreakdown,
|
||||
referrerBreakdown,
|
||||
countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
|
||||
private static DateTime? GetStartDate(string? period)
|
||||
{
|
||||
return period?.ToLower() switch
|
||||
{
|
||||
"24h" => DateTime.UtcNow.AddHours(-24),
|
||||
"7d" => DateTime.UtcNow.AddDays(-7),
|
||||
"30d" => DateTime.UtcNow.AddDays(-30),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractDomain(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
return uri.Host;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return url.Length > 50 ? url[..50] : url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class CreateApiKeyRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public List<string>? Scopes { get; set; }
|
||||
}
|
||||
|
||||
public class CreateApiKeyResponse
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Key { get; set; } // Only returned once on creation!
|
||||
public required string KeyPrefix { get; set; }
|
||||
public List<string>? Scopes { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateApiKeyEndpoint(AppDbContext db)
|
||||
: Endpoint<CreateApiKeyRequest, CreateApiKeyResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/TrackQrApi-keys");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check API key limit (max 10 per workspace)
|
||||
var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct);
|
||||
if (existingCount >= 10)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate secure key: trk_<32 random bytes as base64url>
|
||||
var randomBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var keyValue = "trk_" + Convert.ToBase64String(randomBytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
var keyHash = ComputeSha256Hash(keyValue);
|
||||
var keyPrefix = keyValue[..12] + "...";
|
||||
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Name = req.Name,
|
||||
KeyHash = keyHash,
|
||||
KeyPrefix = keyPrefix,
|
||||
Scopes = req.Scopes,
|
||||
ExpiresAt = req.ExpiresAt,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new CreateApiKeyResponse
|
||||
{
|
||||
Id = apiKey.Id,
|
||||
Name = apiKey.Name,
|
||||
Key = keyValue, // Only returned once!
|
||||
KeyPrefix = keyPrefix,
|
||||
Scopes = apiKey.Scopes,
|
||||
ExpiresAt = apiKey.ExpiresAt,
|
||||
CreatedAt = apiKey.CreatedAt
|
||||
};
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hash(string input)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLower();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class DeleteApiKeyRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteApiKeyEndpoint(AppDbContext db)
|
||||
: Endpoint<DeleteApiKeyRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/TrackQrApi-keys/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var apiKey = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Id == req.Id && k.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (apiKey is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("API key not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
db.ApiKeys.Remove(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class ListApiKeysRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public class ApiKeyDto
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string KeyPrefix { get; set; }
|
||||
public List<string>? Scopes { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class ListApiKeysResponse
|
||||
{
|
||||
public required List<ApiKeyDto> ApiKeys { get; set; }
|
||||
}
|
||||
|
||||
public class ListApiKeysEndpoint(AppDbContext db)
|
||||
: Endpoint<ListApiKeysRequest, ListApiKeysResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/TrackQrApi-keys");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var apiKeys = await db.ApiKeys
|
||||
.Where(k => k.WorkspaceId == req.WorkspaceId)
|
||||
.OrderByDescending(k => k.CreatedAt)
|
||||
.Select(k => new ApiKeyDto
|
||||
{
|
||||
Id = k.Id,
|
||||
Name = k.Name,
|
||||
KeyPrefix = k.KeyPrefix,
|
||||
Scopes = k.Scopes,
|
||||
ExpiresAt = k.ExpiresAt,
|
||||
LastUsedAt = k.LastUsedAt,
|
||||
CreatedAt = k.CreatedAt,
|
||||
IsActive = k.IsActive
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TrackQrApi.Features.Assets.Common;
|
||||
|
||||
public record AssetResponse(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Type,
|
||||
string Mime,
|
||||
long Size,
|
||||
string Url,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record AssetListResponse(
|
||||
IEnumerable<AssetResponse> Assets
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class DeleteAssetRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
: Endpoint<DeleteAssetRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/assets/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteAssetRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var asset = await db.Assets
|
||||
.Include(a => a.Workspace)
|
||||
.FirstOrDefaultAsync(
|
||||
a => a.Id == req.Id && a.WorkspaceId == req.WorkspaceId && a.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Asset not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if asset is in use by any QR code
|
||||
var inUse = await db.QrCodeDesigns
|
||||
.AnyAsync(q => q.LogoAssetId == asset.Id, ct);
|
||||
|
||||
if (inUse)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Cannot delete asset: it is used by one or more QR codes"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete from storage
|
||||
await storage.DeleteAsync(asset.StorageKey);
|
||||
|
||||
// Delete from database
|
||||
db.Assets.Remove(asset);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Asset deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class GetAssetRequest
|
||||
{
|
||||
public string StorageKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GetAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
: Endpoint<GetAssetRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/assets/{StorageKey}");
|
||||
AllowAnonymous(); // Public access to assets
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetAssetRequest req, CancellationToken ct)
|
||||
{
|
||||
// Verify asset exists in database
|
||||
var asset = await db.Assets
|
||||
.FirstOrDefaultAsync(a => a.StorageKey == req.StorageKey, ct);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Asset not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file from storage
|
||||
var result = await storage.GetAsync(req.StorageKey);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Asset file not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var (stream, contentType) = result.Value;
|
||||
|
||||
// Set cache headers
|
||||
HttpContext.Response.Headers.CacheControl = "public, max-age=31536000"; // 1 year
|
||||
HttpContext.Response.ContentType = contentType;
|
||||
|
||||
await stream.CopyToAsync(HttpContext.Response.Body, ct);
|
||||
await stream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Common;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class ListAssetsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public class ListAssetsEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
: Endpoint<ListAssetsRequest, AssetListResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/assets");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListAssetsRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var assets = await db.Assets
|
||||
.Where(a => a.WorkspaceId == req.WorkspaceId)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var response = new AssetListResponse(
|
||||
assets.Select(a => new AssetResponse(
|
||||
a.Id,
|
||||
a.WorkspaceId,
|
||||
a.Type.ToString(),
|
||||
a.Mime,
|
||||
a.Size,
|
||||
storage.GetPublicUrl(a.StorageKey, HttpContext),
|
||||
a.CreatedAt
|
||||
))
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Common;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class UploadAssetRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public IFormFile? File { get; set; }
|
||||
}
|
||||
|
||||
public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
: Endpoint<UploadAssetRequest, AssetResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/assets");
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UploadAssetRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file from form
|
||||
var file = req.File;
|
||||
if (file is null)
|
||||
try
|
||||
{
|
||||
file = HttpContext.Request.Form.Files.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Form access failed - no file uploaded
|
||||
}
|
||||
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("No file uploaded"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
var allowedTypes = new[] { "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml" };
|
||||
if (!allowedTypes.Contains(file.ContentType.ToLowerInvariant()))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Invalid file type. Allowed: PNG, JPEG, GIF, WebP, SVG"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
const long maxSize = 5 * 1024 * 1024;
|
||||
if (file.Length > maxSize)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("File too large. Maximum size is 5MB"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store file
|
||||
await using var stream = file.OpenReadStream();
|
||||
var storageKey = await storage.StoreAsync(stream, file.FileName, file.ContentType);
|
||||
|
||||
// Save to database
|
||||
var asset = new Asset
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Type = AssetType.Logo,
|
||||
StorageKey = storageKey,
|
||||
Mime = file.ContentType,
|
||||
Size = file.Length,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Assets.Add(asset);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new AssetResponse(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.Type.ToString(),
|
||||
asset.Mime,
|
||||
asset.Size,
|
||||
storage.GetPublicUrl(asset.StorageKey, HttpContext),
|
||||
asset.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace TrackQrApi.Features.Assets.Services;
|
||||
|
||||
public interface IAssetStorageService
|
||||
{
|
||||
Task<string> StoreAsync(Stream stream, string filename, string contentType);
|
||||
Task<(Stream Stream, string ContentType)?> GetAsync(string storageKey);
|
||||
Task DeleteAsync(string storageKey);
|
||||
string GetPublicUrl(string storageKey, HttpContext context);
|
||||
}
|
||||
|
||||
public class LocalAssetStorageService : IAssetStorageService
|
||||
{
|
||||
private readonly string _basePath;
|
||||
private readonly ILogger<LocalAssetStorageService> _logger;
|
||||
|
||||
public LocalAssetStorageService(IConfiguration configuration, ILogger<LocalAssetStorageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
|
||||
// Ensure directory exists
|
||||
if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath);
|
||||
}
|
||||
|
||||
public async Task<string> StoreAsync(Stream stream, string filename, string contentType)
|
||||
{
|
||||
// Generate unique storage key
|
||||
var extension = Path.GetExtension(filename);
|
||||
var storageKey = $"{Guid.NewGuid()}{extension}";
|
||||
var filePath = Path.Combine(_basePath, storageKey);
|
||||
|
||||
await using var fileStream = new FileStream(filePath, FileMode.Create);
|
||||
await stream.CopyToAsync(fileStream);
|
||||
|
||||
_logger.LogDebug("Stored asset at {FilePath}", filePath);
|
||||
|
||||
return storageKey;
|
||||
}
|
||||
|
||||
public Task<(Stream Stream, string ContentType)?> GetAsync(string storageKey)
|
||||
{
|
||||
var filePath = Path.Combine(_basePath, storageKey);
|
||||
|
||||
if (!File.Exists(filePath)) return Task.FromResult<(Stream, string)?>(null);
|
||||
|
||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
var contentType = GetContentType(storageKey);
|
||||
|
||||
return Task.FromResult<(Stream, string)?>((stream, contentType));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string storageKey)
|
||||
{
|
||||
var filePath = Path.Combine(_basePath, storageKey);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
_logger.LogDebug("Deleted asset at {FilePath}", filePath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string GetPublicUrl(string storageKey, HttpContext context)
|
||||
{
|
||||
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
|
||||
return $"{baseUrl}/assets/{storageKey}";
|
||||
}
|
||||
|
||||
private static string GetContentType(string filename)
|
||||
{
|
||||
var extension = Path.GetExtension(filename).ToLowerInvariant();
|
||||
return extension switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TrackQrApi.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);
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ChangePasswordRequest
|
||||
{
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ChangePasswordValidator : Validator<ChangePasswordRequest>
|
||||
{
|
||||
public ChangePasswordValidator()
|
||||
{
|
||||
RuleFor(x => x.CurrentPassword)
|
||||
.NotEmpty().WithMessage("Current password is required");
|
||||
|
||||
RuleFor(x => x.NewPassword)
|
||||
.NotEmpty().WithMessage("New password is required")
|
||||
.MinimumLength(8).WithMessage("New password must be at least 8 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/change-password");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangePasswordRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update password
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Password changed successfully"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class DeleteAccountRequest
|
||||
{
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class DeleteAccountValidator : Validator<DeleteAccountRequest>
|
||||
{
|
||||
public DeleteAccountValidator()
|
||||
{
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty().WithMessage("Password is required to delete account");
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteAccountEndpoint(AppDbContext db) : Endpoint<DeleteAccountRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/auth/account");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteAccountRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Password is incorrect"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all user's workspaces (cascade will handle related data)
|
||||
var workspaces = await db.Workspaces.Where(w => w.OwnerUserId == userId).ToListAsync(ct);
|
||||
db.Workspaces.RemoveRange(workspaces);
|
||||
|
||||
// Delete user
|
||||
db.Users.Remove(user);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Security.Cryptography;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Email.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ForgotPasswordRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ForgotPasswordValidator : Validator<ForgotPasswordRequest>
|
||||
{
|
||||
public ForgotPasswordValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("Email is required")
|
||||
.EmailAddress().WithMessage("Invalid email format");
|
||||
}
|
||||
}
|
||||
|
||||
public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
|
||||
: Endpoint<ForgotPasswordRequest, MessageResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/forgot");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// Invalidate any existing tokens for this user
|
||||
var existingTokens = await db.PasswordResetTokens
|
||||
.Where(t => t.UserId == user.Id && !t.Used)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var token in existingTokens) token.Used = true;
|
||||
|
||||
// Generate new token
|
||||
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
|
||||
var passwordResetToken = new PasswordResetToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
Token = resetToken,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(1),
|
||||
Used = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.PasswordResetTokens.Add(passwordResetToken);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Send password reset email
|
||||
try
|
||||
{
|
||||
await emailService.SendPasswordResetEmailAsync(normalizedEmail, resetToken, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to send password reset email to {Email}", normalizedEmail);
|
||||
// Don't fail the request - still return success to prevent email enumeration
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail);
|
||||
}
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("If the email exists, a reset link will be sent"),
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public record ProfileResponse(
|
||||
Guid Id,
|
||||
string Email,
|
||||
bool IsVerified,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public class GetProfileEndpoint(AppDbContext db) : EndpointWithoutRequest<ProfileResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/auth/profile");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new ProfileResponse(
|
||||
u.Id,
|
||||
u.Email,
|
||||
u.VerifiedAt != null,
|
||||
u.CreatedAt
|
||||
))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new { message = "User not found" }, 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(user, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Auth.Settings;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LoginValidator : Validator<LoginRequest>
|
||||
{
|
||||
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> jwtSettings)
|
||||
: Endpoint<LoginRequest, AuthResponse>
|
||||
{
|
||||
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/login");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
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(
|
||||
_jwtSettings.Issuer,
|
||||
_jwtSettings.Audience,
|
||||
claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
var response = new AuthResponse(
|
||||
new JwtSecurityTokenHandler().WriteToken(token),
|
||||
expiresAt,
|
||||
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Auth.Settings;
|
||||
using TrackQrApi.Features.Email.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class RegisterRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class RegisterValidator : Validator<RegisterRequest>
|
||||
{
|
||||
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> jwtSettings, IEmailService emailService)
|
||||
: Endpoint<RegisterRequest, AuthResponse>
|
||||
{
|
||||
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/register");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Create email verification token
|
||||
var tokenBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
|
||||
|
||||
var verificationToken = new EmailVerificationToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
Token = tokenString,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.EmailVerificationTokens.Add(verificationToken);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Send verification email (fire and forget)
|
||||
_ = emailService.SendEmailVerificationAsync(normalizedEmail, tokenString, ct);
|
||||
|
||||
// Send welcome email
|
||||
_ = emailService.SendWelcomeEmailAsync(normalizedEmail, normalizedEmail.Split('@')[0], 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(
|
||||
_jwtSettings.Issuer,
|
||||
_jwtSettings.Audience,
|
||||
claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
return new AuthResponse(
|
||||
new JwtSecurityTokenHandler().WriteToken(token),
|
||||
expiresAt,
|
||||
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Email.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/resend-verification");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.VerifiedAt != null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing tokens
|
||||
var existingTokens = await db.EmailVerificationTokens
|
||||
.Where(t => t.UserId == userId)
|
||||
.ToListAsync(ct);
|
||||
db.EmailVerificationTokens.RemoveRange(existingTokens);
|
||||
|
||||
// Create new token
|
||||
var tokenBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
|
||||
|
||||
var verificationToken = new EmailVerificationToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Token = tokenString,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.EmailVerificationTokens.Add(verificationToken);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Send verification email
|
||||
await emailService.SendEmailVerificationAsync(user.Email, tokenString, ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ResetPasswordRequest
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ValidatorResetPassword : Validator<ResetPasswordRequest>
|
||||
{
|
||||
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(AppDbContext db)
|
||||
: Endpoint<ResetPasswordRequest, MessageResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/reset");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ResetPasswordRequest req, CancellationToken ct)
|
||||
{
|
||||
// Find the token
|
||||
var resetToken = await db.PasswordResetTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == req.Token, ct);
|
||||
|
||||
if (resetToken == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Invalid or expired reset token"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (resetToken.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Reset token has expired"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is already used
|
||||
if (resetToken.Used)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Reset token has already been used"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the user's password
|
||||
resetToken.User.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
|
||||
resetToken.Used = true;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
Logger.LogInformation("Password reset successful for user: {Email}", resetToken.User.Email);
|
||||
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Password has been reset successfully"),
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class UpdateProfileRequest
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProfileValidator : Validator<UpdateProfileRequest>
|
||||
{
|
||||
public UpdateProfileValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.EmailAddress().WithMessage("Invalid email address")
|
||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequest, ProfileResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/auth/profile");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateProfileRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Email) && req.Email != user.Email)
|
||||
{
|
||||
// Check if email is already taken
|
||||
var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
|
||||
if (emailExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.Email = req.Email;
|
||||
user.VerifiedAt = null; // Reset verification when email changes
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new ProfileResponse(
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.VerifiedAt != null,
|
||||
user.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class VerifyEmailRequest
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class VerifyEmailValidator : Validator<VerifyEmailRequest>
|
||||
{
|
||||
public VerifyEmailValidator()
|
||||
{
|
||||
RuleFor(x => x.Token).NotEmpty().WithMessage("Token is required");
|
||||
}
|
||||
}
|
||||
|
||||
public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/verify-email");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(VerifyEmailRequest req, CancellationToken ct)
|
||||
{
|
||||
var token = await db.EmailVerificationTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == req.Token, ct);
|
||||
|
||||
if (token == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
db.EmailVerificationTokens.Remove(token);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark user as verified
|
||||
token.User.VerifiedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove the token
|
||||
db.EmailVerificationTokens.Remove(token);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TrackQrApi.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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TrackQrApi.Features.Billing.Common;
|
||||
|
||||
public record CheckoutSessionRequest(
|
||||
Guid WorkspaceId,
|
||||
string Plan,
|
||||
string SuccessUrl,
|
||||
string CancelUrl
|
||||
);
|
||||
|
||||
public record CheckoutSessionResponse(string Url);
|
||||
|
||||
public record PortalSessionRequest(string ReturnUrl);
|
||||
|
||||
public record PortalSessionResponse(string Url);
|
||||
|
||||
public record SubscriptionResponse(
|
||||
Guid WorkspaceId,
|
||||
string Plan,
|
||||
string? SubscriptionId,
|
||||
DateTime? CurrentPeriodEnd,
|
||||
bool IsActive,
|
||||
bool CancelAtPeriodEnd
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Billing.Common;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
|
||||
{
|
||||
public CreateCheckoutSessionValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.Plan)
|
||||
.NotEmpty()
|
||||
.Must(p => p == "Pro" || p == "Business")
|
||||
.WithMessage("Plan must be 'Pro' or 'Business'");
|
||||
RuleFor(x => x.SuccessUrl).NotEmpty().Must(BeValidUrl);
|
||||
RuleFor(x => x.CancelUrl).NotEmpty().Must(BeValidUrl);
|
||||
}
|
||||
|
||||
private static bool BeValidUrl(string url)
|
||||
{
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService stripeService)
|
||||
: Endpoint<CheckoutSessionRequest, CheckoutSessionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/billing/checkout");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CheckoutSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse(
|
||||
"Workspace already has an active subscription. Use the billing portal to manage it."),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var plan = Enum.Parse<WorkspacePlan>(req.Plan);
|
||||
|
||||
try
|
||||
{
|
||||
var checkoutUrl = await stripeService.CreateCheckoutSessionAsync(
|
||||
userId,
|
||||
req.WorkspaceId,
|
||||
plan,
|
||||
req.SuccessUrl,
|
||||
req.CancelUrl,
|
||||
ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new CheckoutSessionResponse(checkoutUrl), cancellation: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse($"Failed to create checkout session: {ex.Message}"),
|
||||
500,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Billing.Common;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
|
||||
{
|
||||
public CreatePortalSessionValidator()
|
||||
{
|
||||
RuleFor(x => x.ReturnUrl).NotEmpty().Must(BeValidUrl);
|
||||
}
|
||||
|
||||
private static bool BeValidUrl(string url)
|
||||
{
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreatePortalSessionEndpoint(IStripeService stripeService)
|
||||
: Endpoint<PortalSessionRequest, PortalSessionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/billing/portal");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PortalSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
try
|
||||
{
|
||||
var portalUrl = await stripeService.CreateCustomerPortalSessionAsync(
|
||||
userId,
|
||||
req.ReturnUrl,
|
||||
ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new PortalSessionResponse(portalUrl), cancellation: ct);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse(ex.Message),
|
||||
400,
|
||||
cancellation: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse($"Failed to create portal session: {ex.Message}"),
|
||||
500,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Billing.Common;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class GetSubscriptionRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeService)
|
||||
: Endpoint<GetSubscriptionRequest, SubscriptionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/subscription");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetSubscriptionRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var isActive = workspace.Plan != WorkspacePlan.Free;
|
||||
var cancelAtPeriodEnd = false;
|
||||
|
||||
// Get live subscription status from Stripe if exists
|
||||
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
|
||||
{
|
||||
var subscription = await stripeService.GetSubscriptionAsync(workspace.StripeSubscriptionId, ct);
|
||||
if (subscription != null)
|
||||
{
|
||||
isActive = subscription.Status == "active" || subscription.Status == "trialing";
|
||||
cancelAtPeriodEnd = subscription.CancelAtPeriodEnd;
|
||||
}
|
||||
}
|
||||
|
||||
var response = new SubscriptionResponse(
|
||||
workspace.Id,
|
||||
workspace.Plan.ToString(),
|
||||
workspace.StripeSubscriptionId,
|
||||
workspace.SubscriptionEndsAt,
|
||||
isActive,
|
||||
cancelAtPeriodEnd
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
using TrackQrApi.Features.Billing.Settings;
|
||||
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class StripeWebhookEndpoint(
|
||||
IStripeService stripeService,
|
||||
IOptions<StripeSettings> settings,
|
||||
ILogger<StripeWebhookEndpoint> logger)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
private readonly StripeSettings _settings = settings.Value;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/billing/webhook");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var stripeSignature = HttpContext.Request.Headers["Stripe-Signature"].ToString();
|
||||
var stripeEvent = EventUtility.ConstructEvent(
|
||||
json,
|
||||
stripeSignature,
|
||||
_settings.WebhookSecret
|
||||
);
|
||||
|
||||
logger.LogInformation("Received Stripe event: {EventType} ({EventId})", stripeEvent.Type, stripeEvent.Id);
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case "checkout.session.completed":
|
||||
var session = stripeEvent.Data.Object as Session;
|
||||
if (session != null) await stripeService.HandleCheckoutCompletedAsync(session, ct);
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
var updatedSubscription = stripeEvent.Data.Object as Subscription;
|
||||
if (updatedSubscription != null)
|
||||
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
|
||||
break;
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
var deletedSubscription = stripeEvent.Data.Object as Subscription;
|
||||
if (deletedSubscription != null)
|
||||
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
|
||||
break;
|
||||
|
||||
case "invoice.payment_failed":
|
||||
logger.LogWarning("Payment failed for invoice: {InvoiceId}",
|
||||
(stripeEvent.Data.Object as Invoice)?.Id);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.LogDebug("Unhandled Stripe event type: {EventType}", stripeEvent.Type);
|
||||
break;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(new { received = true }, cancellation: ct);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
logger.LogError(ex, "Stripe webhook signature verification failed");
|
||||
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400,
|
||||
cancellation: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing Stripe webhook");
|
||||
await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Billing.Settings;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Billing.Services;
|
||||
|
||||
public interface IStripeService
|
||||
{
|
||||
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl,
|
||||
string cancelUrl, CancellationToken ct = default);
|
||||
|
||||
Task<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
|
||||
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||
Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default);
|
||||
Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default);
|
||||
Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default);
|
||||
string GetPriceIdForPlan(WorkspacePlan plan);
|
||||
WorkspacePlan GetPlanForPriceId(string priceId);
|
||||
}
|
||||
|
||||
public class StripeService : IStripeService
|
||||
{
|
||||
private readonly ILogger<StripeService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly StripeSettings _settings;
|
||||
|
||||
public StripeService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<StripeSettings> settings,
|
||||
ILogger<StripeService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
StripeConfiguration.ApiKey = _settings.SecretKey;
|
||||
}
|
||||
|
||||
public async Task<string> CreateCheckoutSessionAsync(
|
||||
Guid userId,
|
||||
Guid workspaceId,
|
||||
WorkspacePlan plan,
|
||||
string successUrl,
|
||||
string cancelUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct)
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
|
||||
// Get or create Stripe customer
|
||||
var customerId = user.StripeCustomerId;
|
||||
if (string.IsNullOrEmpty(customerId))
|
||||
{
|
||||
var customerService = new CustomerService();
|
||||
var customer = await customerService.CreateAsync(new CustomerCreateOptions
|
||||
{
|
||||
Email = user.Email,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["user_id"] = userId.ToString()
|
||||
}
|
||||
}, cancellationToken: ct);
|
||||
|
||||
customerId = customer.Id;
|
||||
user.StripeCustomerId = customerId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
var priceId = GetPriceIdForPlan(plan);
|
||||
if (string.IsNullOrEmpty(priceId)) throw new InvalidOperationException($"No price configured for plan: {plan}");
|
||||
|
||||
var sessionService = new SessionService();
|
||||
var session = await sessionService.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Customer = customerId,
|
||||
Mode = "subscription",
|
||||
PaymentMethodTypes = ["card"],
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Price = priceId,
|
||||
Quantity = 1
|
||||
}
|
||||
],
|
||||
SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
CancelUrl = cancelUrl,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["user_id"] = userId.ToString(),
|
||||
["workspace_id"] = workspaceId.ToString(),
|
||||
["plan"] = plan.ToString()
|
||||
},
|
||||
SubscriptionData = new SessionSubscriptionDataOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["user_id"] = userId.ToString(),
|
||||
["workspace_id"] = workspaceId.ToString()
|
||||
}
|
||||
}
|
||||
}, cancellationToken: ct);
|
||||
|
||||
return session.Url;
|
||||
}
|
||||
|
||||
public async Task<string> CreateCustomerPortalSessionAsync(
|
||||
Guid userId,
|
||||
string returnUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct)
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
|
||||
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
||||
throw new InvalidOperationException("User has no Stripe customer");
|
||||
|
||||
var sessionService = new Stripe.BillingPortal.SessionService();
|
||||
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
|
||||
{
|
||||
Customer = user.StripeCustomerId,
|
||||
ReturnUrl = returnUrl
|
||||
}, cancellationToken: ct);
|
||||
|
||||
return session.Url;
|
||||
}
|
||||
|
||||
public async Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
|
||||
{
|
||||
var service = new SubscriptionService();
|
||||
try
|
||||
{
|
||||
return await service.GetAsync(subscriptionId, cancellationToken: ct);
|
||||
}
|
||||
catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
|
||||
{
|
||||
var service = new SubscriptionService();
|
||||
await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions
|
||||
{
|
||||
InvoiceNow = false,
|
||||
Prorate = false
|
||||
}, cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default)
|
||||
{
|
||||
var workspaceIdStr = session.Metadata.GetValueOrDefault("workspace_id");
|
||||
var planStr = session.Metadata.GetValueOrDefault("plan");
|
||||
|
||||
if (string.IsNullOrEmpty(workspaceIdStr) || string.IsNullOrEmpty(planStr))
|
||||
{
|
||||
_logger.LogWarning("Checkout session missing metadata: {SessionId}", session.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(workspaceIdStr, out var workspaceId))
|
||||
{
|
||||
_logger.LogWarning("Invalid workspace_id in checkout session: {WorkspaceId}", workspaceIdStr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<WorkspacePlan>(planStr, out var plan))
|
||||
{
|
||||
_logger.LogWarning("Invalid plan in checkout session: {Plan}", planStr);
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces.FindAsync([workspaceId], ct);
|
||||
if (workspace == null)
|
||||
{
|
||||
_logger.LogWarning("Workspace not found for checkout: {WorkspaceId}", workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.Plan = plan;
|
||||
workspace.StripeSubscriptionId = session.SubscriptionId;
|
||||
|
||||
// Get subscription to set end date
|
||||
if (!string.IsNullOrEmpty(session.SubscriptionId))
|
||||
{
|
||||
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
|
||||
if (subscription != null) workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} upgraded to {Plan} via checkout {SessionId}",
|
||||
workspaceId, plan, session.Id);
|
||||
}
|
||||
|
||||
public async Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
_logger.LogWarning("No workspace found for subscription: {SubscriptionId}", subscription.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update plan based on price
|
||||
var priceId = subscription.Items.Data.FirstOrDefault()?.Price?.Id;
|
||||
if (!string.IsNullOrEmpty(priceId))
|
||||
{
|
||||
var newPlan = GetPlanForPriceId(priceId);
|
||||
if (workspace.Plan != newPlan)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} plan changed from {OldPlan} to {NewPlan}",
|
||||
workspace.Id, workspace.Plan, newPlan);
|
||||
workspace.Plan = newPlan;
|
||||
}
|
||||
}
|
||||
|
||||
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
||||
|
||||
// Handle cancellation at period end
|
||||
if (subscription.CancelAtPeriodEnd)
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
|
||||
workspace.Id, subscription.CurrentPeriodEnd);
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
_logger.LogWarning("No workspace found for deleted subscription: {SubscriptionId}", subscription.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} subscription deleted, downgrading to Free",
|
||||
workspace.Id);
|
||||
|
||||
workspace.Plan = WorkspacePlan.Free;
|
||||
workspace.StripeSubscriptionId = null;
|
||||
workspace.SubscriptionEndsAt = null;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public string GetPriceIdForPlan(WorkspacePlan plan)
|
||||
{
|
||||
return plan switch
|
||||
{
|
||||
WorkspacePlan.Pro => _settings.ProPriceId,
|
||||
WorkspacePlan.Business => _settings.BusinessPriceId,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public WorkspacePlan GetPlanForPriceId(string priceId)
|
||||
{
|
||||
if (priceId == _settings.ProPriceId)
|
||||
return WorkspacePlan.Pro;
|
||||
if (priceId == _settings.BusinessPriceId)
|
||||
return WorkspacePlan.Business;
|
||||
return WorkspacePlan.Free;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TrackQrApi.Features.Billing.Settings;
|
||||
|
||||
public class StripeSettings
|
||||
{
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
public string WebhookSecret { get; set; } = string.Empty;
|
||||
public string ProPriceId { get; set; } = string.Empty;
|
||||
public string BusinessPriceId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TrackQrApi.Features.Domains.Common;
|
||||
|
||||
public record DomainResponse(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Hostname,
|
||||
string Status,
|
||||
string VerificationToken,
|
||||
string VerificationRecord,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record DomainListResponse(
|
||||
IEnumerable<DomainResponse> Domains
|
||||
);
|
||||
|
||||
public record DomainVerificationResponse(
|
||||
Guid Id,
|
||||
string Hostname,
|
||||
bool IsVerified,
|
||||
string Status,
|
||||
string? Message
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class AddDomainRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public string Hostname { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AddDomainValidator : Validator<AddDomainRequest>
|
||||
{
|
||||
public AddDomainValidator()
|
||||
{
|
||||
RuleFor(x => x.Hostname)
|
||||
.NotEmpty().WithMessage("Hostname is required")
|
||||
.MaximumLength(255).WithMessage("Hostname must not exceed 255 characters")
|
||||
.Matches(@"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$")
|
||||
.WithMessage("Invalid hostname format");
|
||||
}
|
||||
}
|
||||
|
||||
public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<AddDomainRequest, DomainResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/domains");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(AddDomainRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateDomainAsync(req.WorkspaceId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Domain limit reached. Please upgrade your plan to add more custom domains."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize hostname (lowercase, no trailing dots)
|
||||
var hostname = req.Hostname.ToLowerInvariant().TrimEnd('.');
|
||||
|
||||
// Check if domain already exists globally
|
||||
var domainExists = await db.Domains
|
||||
.AnyAsync(d => d.Hostname == hostname, ct);
|
||||
|
||||
if (domainExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain is already registered"), 409,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
var verificationToken = GenerateVerificationToken();
|
||||
|
||||
var domain = new Domain
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Hostname = hostname,
|
||||
Status = DomainStatus.Pending,
|
||||
VerificationToken = verificationToken,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Domains.Add(domain);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new DomainResponse(
|
||||
domain.Id,
|
||||
domain.WorkspaceId,
|
||||
domain.Hostname,
|
||||
domain.Status.ToString(),
|
||||
domain.VerificationToken,
|
||||
GetVerificationRecord(domain.VerificationToken),
|
||||
domain.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
|
||||
private static string GenerateVerificationToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(16);
|
||||
return $"trakqr-verify-{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string GetVerificationRecord(string token)
|
||||
{
|
||||
return $"TXT _trakqr-verification {token}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class DeleteDomainRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteDomainEndpoint(AppDbContext db)
|
||||
: Endpoint<DeleteDomainRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/domains/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteDomainRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var domain = await db.Domains
|
||||
.Include(d => d.Workspace)
|
||||
.FirstOrDefaultAsync(
|
||||
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (domain is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any links are using this domain
|
||||
var linksUsingDomain = await db.ShortLinks
|
||||
.AnyAsync(l => l.DomainId == domain.Id, ct);
|
||||
|
||||
if (linksUsingDomain)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Cannot delete domain: it has associated short links"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
db.Domains.Remove(domain);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class GetDomainRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class GetDomainEndpoint(AppDbContext db)
|
||||
: Endpoint<GetDomainRequest, DomainResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/domains/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetDomainRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var domain = await db.Domains
|
||||
.Where(d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (domain is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = new DomainResponse(
|
||||
domain.Id,
|
||||
domain.WorkspaceId,
|
||||
domain.Hostname,
|
||||
domain.Status.ToString(),
|
||||
domain.VerificationToken,
|
||||
$"TXT _trakqr-verification {domain.VerificationToken}",
|
||||
domain.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class ListDomainsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public class ListDomainsEndpoint(AppDbContext db)
|
||||
: Endpoint<ListDomainsRequest, DomainListResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/domains");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListDomainsRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var domains = await db.Domains
|
||||
.Where(d => d.WorkspaceId == req.WorkspaceId)
|
||||
.OrderByDescending(d => d.CreatedAt)
|
||||
.Select(d => new DomainResponse(
|
||||
d.Id,
|
||||
d.WorkspaceId,
|
||||
d.Hostname,
|
||||
d.Status.ToString(),
|
||||
d.VerificationToken,
|
||||
$"TXT _trakqr-verification {d.VerificationToken}",
|
||||
d.CreatedAt
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new DomainListResponse(domains), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class VerifyDomainRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class VerifyDomainEndpoint(AppDbContext db)
|
||||
: Endpoint<VerifyDomainRequest, DomainVerificationResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/domains/{Id}/verify");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(VerifyDomainRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var domain = await db.Domains
|
||||
.Include(d => d.Workspace)
|
||||
.FirstOrDefaultAsync(
|
||||
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (domain is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Already verified or active
|
||||
if (domain.Status != DomainStatus.Pending)
|
||||
{
|
||||
var alreadyResponse = new DomainVerificationResponse(
|
||||
domain.Id,
|
||||
domain.Hostname,
|
||||
true,
|
||||
domain.Status.ToString(),
|
||||
"Domain is already verified"
|
||||
);
|
||||
await HttpContext.Response.SendAsync(alreadyResponse, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check DNS TXT record
|
||||
var isVerified = await CheckDnsVerificationAsync(domain.Hostname, domain.VerificationToken);
|
||||
|
||||
if (isVerified)
|
||||
{
|
||||
domain.Status = DomainStatus.Verified;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var successResponse = new DomainVerificationResponse(
|
||||
domain.Id,
|
||||
domain.Hostname,
|
||||
true,
|
||||
domain.Status.ToString(),
|
||||
"Domain verified successfully"
|
||||
);
|
||||
await HttpContext.Response.SendAsync(successResponse, cancellation: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var failedResponse = new DomainVerificationResponse(
|
||||
domain.Id,
|
||||
domain.Hostname,
|
||||
false,
|
||||
domain.Status.ToString(),
|
||||
$"Verification failed. Please add a TXT record for _trakqr-verification.{domain.Hostname} with value: {domain.VerificationToken}"
|
||||
);
|
||||
await HttpContext.Response.SendAsync(failedResponse, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<bool> CheckDnsVerificationAsync(string hostname, string expectedToken)
|
||||
{
|
||||
// For testing purposes, we'll check if the domain starts with "verified-"
|
||||
// In production, this would be replaced with actual DNS TXT record lookup
|
||||
// using a library like DnsClient
|
||||
var isVerified = hostname.StartsWith("verified-");
|
||||
return Task.FromResult(isVerified);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TrackQrApi.Features.Email.Templates;
|
||||
|
||||
namespace TrackQrApi.Features.Email.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Development email service that logs emails to console instead of sending them.
|
||||
/// Useful for testing without a real SMTP server.
|
||||
/// </summary>
|
||||
public class ConsoleEmailService(
|
||||
IOptions<EmailSettings> settings,
|
||||
ILogger<ConsoleEmailService> logger)
|
||||
: IEmailService
|
||||
{
|
||||
private readonly EmailSettings _settings = settings.Value;
|
||||
|
||||
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
|
||||
{
|
||||
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
|
||||
var (subject, _, textBody) = EmailTemplates.PasswordReset(resetUrl);
|
||||
|
||||
LogEmail(toEmail, subject, textBody, resetUrl);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
|
||||
{
|
||||
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
|
||||
var (subject, _, textBody) = EmailTemplates.EmailVerification(verifyUrl);
|
||||
|
||||
LogEmail(toEmail, subject, textBody, verifyUrl);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
|
||||
{
|
||||
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
|
||||
var (subject, _, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
|
||||
|
||||
LogEmail(toEmail, subject, textBody, dashboardUrl);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
|
||||
{
|
||||
logger.LogInformation($"""
|
||||
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ EMAIL (Console Mode) ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
To: {toEmail}
|
||||
Subject: {subject}
|
||||
Action URL: {actionUrl}
|
||||
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace TrackQrApi.Features.Email.Services;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default);
|
||||
Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default);
|
||||
Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class EmailSettings
|
||||
{
|
||||
public string Provider { get; set; } = "smtp"; // smtp, sendgrid, ses
|
||||
public string FromEmail { get; set; } = "noreply@trakqr.com";
|
||||
public string FromName { get; set; } = "TrakQR";
|
||||
public string BaseUrl { get; set; } = "https://trakqr.com";
|
||||
|
||||
// SMTP settings
|
||||
public SmtpSettings? Smtp { get; set; }
|
||||
|
||||
// SendGrid settings
|
||||
public SendGridSettings? SendGrid { get; set; }
|
||||
}
|
||||
|
||||
public class SmtpSettings
|
||||
{
|
||||
public string Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 587;
|
||||
public bool UseSsl { get; set; } = true;
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
|
||||
public class SendGridSettings
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TrackQrApi.Features.Email.Templates;
|
||||
|
||||
namespace TrackQrApi.Features.Email.Services;
|
||||
|
||||
public class SmtpEmailService : IEmailService
|
||||
{
|
||||
private readonly ILogger<SmtpEmailService> _logger;
|
||||
private readonly EmailSettings _settings;
|
||||
|
||||
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
|
||||
{
|
||||
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
|
||||
var (subject, htmlBody, textBody) = EmailTemplates.PasswordReset(resetUrl);
|
||||
|
||||
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
|
||||
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
|
||||
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
|
||||
|
||||
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
|
||||
_logger.LogInformation("Verification email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
public async Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
|
||||
{
|
||||
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
|
||||
var (subject, htmlBody, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
|
||||
|
||||
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
|
||||
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_settings.Smtp == null)
|
||||
{
|
||||
_logger.LogWarning("SMTP settings not configured. Email not sent to {Email}", toEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var message = new MailMessage
|
||||
{
|
||||
From = new MailAddress(_settings.FromEmail, _settings.FromName),
|
||||
Subject = subject,
|
||||
IsBodyHtml = true,
|
||||
Body = htmlBody
|
||||
};
|
||||
|
||||
message.To.Add(new MailAddress(toEmail));
|
||||
|
||||
// Add plain text alternative
|
||||
var plainTextView = AlternateView.CreateAlternateViewFromString(textBody, null, "text/plain");
|
||||
var htmlView = AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html");
|
||||
message.AlternateViews.Add(plainTextView);
|
||||
message.AlternateViews.Add(htmlView);
|
||||
|
||||
using var client = new SmtpClient(_settings.Smtp.Host, _settings.Smtp.Port)
|
||||
{
|
||||
EnableSsl = _settings.Smtp.UseSsl,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
|
||||
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
|
||||
|
||||
await client.SendMailAsync(message, ct);
|
||||
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send email to {Email}", toEmail);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
namespace TrackQrApi.Features.Email.Templates;
|
||||
|
||||
public static class EmailTemplates
|
||||
{
|
||||
public static (string Subject, string HtmlBody, string TextBody) PasswordReset(string resetUrl)
|
||||
{
|
||||
var subject = "Reset your TrakQR password";
|
||||
|
||||
var htmlBody = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Reset Your Password</title>
|
||||
</head>
|
||||
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
|
||||
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
|
||||
<tr>
|
||||
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
|
||||
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 20px;"">
|
||||
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Reset your password</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">We received a request to reset your password. Click the button below to choose a new password.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<a href=""{resetUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Reset Password</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">This link will expire in 1 hour.</p>
|
||||
<p style=""margin: 10px 0 0 0;"">If you didn't request this, you can safely ignore this email.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
|
||||
<p style=""margin: 0;"">© TrakQR. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
var textBody = $@"Reset Your Password
|
||||
|
||||
We received a request to reset your TrakQR password.
|
||||
|
||||
Click the link below to reset your password:
|
||||
{resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
- The TrakQR Team";
|
||||
|
||||
return (subject, htmlBody, textBody);
|
||||
}
|
||||
|
||||
public static (string Subject, string HtmlBody, string TextBody) EmailVerification(string verifyUrl)
|
||||
{
|
||||
var subject = "Verify your TrakQR email";
|
||||
|
||||
var htmlBody = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Verify Your Email</title>
|
||||
</head>
|
||||
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
|
||||
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
|
||||
<tr>
|
||||
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
|
||||
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 20px;"">
|
||||
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Verify your email</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">Thanks for signing up! Please verify your email address to get started with TrakQR.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<a href=""{verifyUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Verify Email</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">This link will expire in 24 hours.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
|
||||
<p style=""margin: 0;"">© TrakQR. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
var textBody = $@"Verify Your Email
|
||||
|
||||
Thanks for signing up for TrakQR!
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
{verifyUrl}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
- The TrakQR Team";
|
||||
|
||||
return (subject, htmlBody, textBody);
|
||||
}
|
||||
|
||||
public static (string Subject, string HtmlBody, string TextBody) Welcome(string userName, string dashboardUrl)
|
||||
{
|
||||
var subject = "Welcome to TrakQR!";
|
||||
|
||||
var htmlBody = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Welcome to TrakQR</title>
|
||||
</head>
|
||||
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
|
||||
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
|
||||
<tr>
|
||||
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
|
||||
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 20px;"">
|
||||
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Welcome to TrakQR!</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">Hi {userName},</p>
|
||||
<p style=""margin: 10px 0 0 0;"">You're all set! Start creating short links and beautiful QR codes with powerful analytics.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<a href=""{dashboardUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Go to Dashboard</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""padding: 20px; background: #f9f9f9; border-radius: 12px;"">
|
||||
<h3 style=""margin: 0 0 15px 0; font-size: 16px; color: #1a1a1a;"">Get started:</h3>
|
||||
<ul style=""margin: 0; padding-left: 20px; color: #666666; line-height: 1.8;"">
|
||||
<li>Create your first short link</li>
|
||||
<li>Design a custom QR code</li>
|
||||
<li>Track clicks and scans in real-time</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
|
||||
<p style=""margin: 0;"">© TrakQR. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
var textBody = $@"Welcome to TrakQR!
|
||||
|
||||
Hi {userName},
|
||||
|
||||
You're all set! Start creating short links and beautiful QR codes with powerful analytics.
|
||||
|
||||
Get started:
|
||||
- Create your first short link
|
||||
- Design a custom QR code
|
||||
- Track clicks and scans in real-time
|
||||
|
||||
Go to your dashboard: {dashboardUrl}
|
||||
|
||||
- The TrakQR Team";
|
||||
|
||||
return (subject, htmlBody, textBody);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Events.Services;
|
||||
|
||||
public interface IEventTrackingService
|
||||
{
|
||||
Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context);
|
||||
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
|
||||
}
|
||||
|
||||
public class EventTrackingService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IGeoIpService geoIpService,
|
||||
ILogger<EventTrackingService> logger)
|
||||
: IEventTrackingService
|
||||
{
|
||||
// Dedupe window - same visitor clicking same link within this window counts as one
|
||||
private static readonly TimeSpan DedupeWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
public Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context)
|
||||
{
|
||||
// Fire and forget - don't block the redirect
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to track click event for link {ShortLinkId}", shortLinkId);
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context)
|
||||
{
|
||||
// Fire and forget - don't block the redirect
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to track scan event for QR {QRCodeId}", qrCodeId);
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task TrackEventInternalAsync(
|
||||
Guid workspaceId,
|
||||
Guid shortLinkId,
|
||||
Guid? qrCodeId,
|
||||
EventType eventType,
|
||||
HttpContext context)
|
||||
{
|
||||
// Create a new scope for database access (since we're in a background task)
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var ipAddress = GetClientIpAddress(context);
|
||||
var userAgent = context.Request.Headers.UserAgent.ToString();
|
||||
var referrer = context.Request.Headers.Referer.ToString();
|
||||
|
||||
var ipHash = HashIpAddress(ipAddress);
|
||||
var deviceType = ParseDeviceType(userAgent);
|
||||
var countryCode = geoIpService.GetCountryCode(ipAddress);
|
||||
var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId);
|
||||
|
||||
// Check for duplicate within the dedupe window
|
||||
var cutoff = DateTime.UtcNow.Subtract(DedupeWindow);
|
||||
var isDuplicate = await db.Events
|
||||
.AnyAsync(e => e.DedupeKey == dedupeKey && e.Timestamp > cutoff);
|
||||
|
||||
if (isDuplicate)
|
||||
{
|
||||
logger.LogDebug("Skipping duplicate event for link {ShortLinkId}", shortLinkId);
|
||||
return;
|
||||
}
|
||||
|
||||
var evt = new Event
|
||||
{
|
||||
WorkspaceId = workspaceId,
|
||||
ShortLinkId = shortLinkId,
|
||||
QRCodeId = qrCodeId,
|
||||
Type = eventType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IpHash = ipHash,
|
||||
UserAgent = TruncateString(userAgent, 512),
|
||||
Referrer = TruncateString(referrer, 2048),
|
||||
CountryCode = countryCode,
|
||||
DeviceType = deviceType,
|
||||
DedupeKey = dedupeKey
|
||||
};
|
||||
|
||||
db.Events.Add(evt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogDebug("Tracked {EventType} event for link {ShortLinkId}", eventType, shortLinkId);
|
||||
}
|
||||
|
||||
private static string GetClientIpAddress(HttpContext context)
|
||||
{
|
||||
// Check for forwarded headers (when behind a proxy/load balancer)
|
||||
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(forwardedFor))
|
||||
// Take the first IP in the chain (client IP)
|
||||
return forwardedFor.Split(',')[0].Trim();
|
||||
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private static string HashIpAddress(string ipAddress)
|
||||
{
|
||||
// Use SHA256 to hash the IP for privacy
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ipAddress));
|
||||
return Convert.ToHexString(bytes)[..16]; // First 16 chars is enough
|
||||
}
|
||||
|
||||
private static string ParseDeviceType(string userAgent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userAgent))
|
||||
return "Unknown";
|
||||
|
||||
var ua = userAgent.ToLowerInvariant();
|
||||
|
||||
// Check for mobile devices
|
||||
if (Regex.IsMatch(ua, @"mobile|android|iphone|ipad|ipod|blackberry|windows phone"))
|
||||
{
|
||||
if (Regex.IsMatch(ua, @"ipad|tablet|android(?!.*mobile)"))
|
||||
return "Tablet";
|
||||
return "Mobile";
|
||||
}
|
||||
|
||||
// Check for bots/crawlers
|
||||
if (Regex.IsMatch(ua, @"bot|crawler|spider|slurp|googlebot|bingbot"))
|
||||
return "Bot";
|
||||
|
||||
return "Desktop";
|
||||
}
|
||||
|
||||
private static string GenerateDedupeKey(string ipHash, Guid shortLinkId, Guid? qrCodeId)
|
||||
{
|
||||
// Combine IP hash + link ID + optional QR ID
|
||||
var combined = $"{ipHash}:{shortLinkId}:{qrCodeId?.ToString() ?? "none"}";
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToHexString(bytes)[..32];
|
||||
}
|
||||
|
||||
private static string? TruncateString(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return null;
|
||||
|
||||
return value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using MaxMind.GeoIP2;
|
||||
|
||||
namespace TrackQrApi.Features.Events.Services;
|
||||
|
||||
public interface IGeoIpService
|
||||
{
|
||||
string? GetCountryCode(string ipAddress);
|
||||
}
|
||||
|
||||
public class GeoIpService : IGeoIpService, IDisposable
|
||||
{
|
||||
private readonly ILogger<GeoIpService> _logger;
|
||||
private readonly DatabaseReader? _reader;
|
||||
|
||||
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var dbPath = configuration["GeoIP:DatabasePath"];
|
||||
|
||||
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
|
||||
try
|
||||
{
|
||||
_reader = new DatabaseReader(dbPath);
|
||||
_logger.LogInformation("GeoIP database loaded from {Path}", dbPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
|
||||
}
|
||||
else
|
||||
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_reader?.Dispose();
|
||||
}
|
||||
|
||||
public string? GetCountryCode(string ipAddress)
|
||||
{
|
||||
if (_reader == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Handle localhost and private IPs
|
||||
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) return null;
|
||||
|
||||
if (!IPAddress.TryParse(ipAddress, out var ip)) return null;
|
||||
|
||||
if (_reader.TryCountry(ip, out var response)) return response?.Country?.IsoCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to lookup country for IP {IP}", ipAddress);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsPrivateIp(string ipAddress)
|
||||
{
|
||||
if (!IPAddress.TryParse(ipAddress, out var ip))
|
||||
return false;
|
||||
|
||||
var bytes = ip.GetAddressBytes();
|
||||
|
||||
// Check for IPv4 private ranges
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
// 10.0.0.0/8
|
||||
if (bytes[0] == 10) return true;
|
||||
// 172.16.0.0/12
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
12
src/TrackApi/TrackQrApi/Features/Links/Common/LinkDto.cs
Normal file
12
src/TrackApi/TrackQrApi/Features/Links/Common/LinkDto.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace TrackQrApi.Features.Links.Common;
|
||||
|
||||
public class LinkDto
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public required string DestinationUrl { get; set; }
|
||||
public required string? Title { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public int ClickCount { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace TrackQrApi.Features.Links.Common;
|
||||
|
||||
public record LinkResponse(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid? ProjectId,
|
||||
Guid? DomainId,
|
||||
string Slug,
|
||||
string DestinationUrl,
|
||||
string? Title,
|
||||
string Status,
|
||||
DateTime? ExpiresAt,
|
||||
bool HasPassword,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
DateTime? DeletedAt = null
|
||||
);
|
||||
|
||||
public record LinkListResponse(
|
||||
IEnumerable<LinkResponse> Links
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Common;
|
||||
|
||||
public static class SlugGenerator
|
||||
{
|
||||
private const string Chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
private const int DefaultLength = 7;
|
||||
|
||||
public static string Generate(int length = DefaultLength)
|
||||
{
|
||||
return RandomNumberGenerator.GetString(Chars, length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class BulkCreateLinksRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required List<BulkLinkItem> Links { get; set; }
|
||||
}
|
||||
|
||||
public class BulkLinkItem
|
||||
{
|
||||
public required string DestinationUrl { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Slug { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateLinksResponse
|
||||
{
|
||||
public required List<LinkDto> Created { get; set; }
|
||||
public required List<BulkLinkError> Errors { get; set; }
|
||||
}
|
||||
|
||||
public class BulkLinkError
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public required string Error { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateLinksEndpoint(AppDbContext db)
|
||||
: Endpoint<BulkCreateLinksRequest, BulkCreateLinksResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/links/bulk");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(BulkCreateLinksRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit bulk creation to 100 links at a time
|
||||
if (req.Links.Count > 100)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var created = new List<LinkDto>();
|
||||
var errors = new List<BulkLinkError>();
|
||||
|
||||
// Check for plan limits
|
||||
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
|
||||
var linkLimit = GetPlanLinkLimit(workspace.Plan);
|
||||
|
||||
for (var i = 0; i < req.Links.Count; i++)
|
||||
{
|
||||
var item = req.Links[i];
|
||||
|
||||
// Validate URL
|
||||
if (!Uri.TryCreate(item.DestinationUrl, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
errors.Add(new BulkLinkError
|
||||
{
|
||||
Index = i,
|
||||
Url = item.DestinationUrl,
|
||||
Error = "Invalid URL"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (linkLimit.HasValue && currentLinkCount + created.Count >= linkLimit.Value)
|
||||
{
|
||||
errors.Add(new BulkLinkError
|
||||
{
|
||||
Index = i,
|
||||
Url = item.DestinationUrl,
|
||||
Error = "Plan link limit reached"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate or validate slug
|
||||
var slug = item.Slug;
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
slug = GenerateSlug();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if slug is taken
|
||||
var slugTaken = await db.ShortLinks.AnyAsync(l => l.Slug == slug, ct);
|
||||
if (slugTaken)
|
||||
{
|
||||
errors.Add(new BulkLinkError
|
||||
{
|
||||
Index = i,
|
||||
Url = item.DestinationUrl,
|
||||
Error = $"Slug '{slug}' is already taken"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var link = new ShortLink
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Slug = slug,
|
||||
DestinationUrl = item.DestinationUrl,
|
||||
Title = item.Title,
|
||||
Status = ShortLinkStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.ShortLinks.Add(link);
|
||||
|
||||
created.Add(new LinkDto
|
||||
{
|
||||
Id = link.Id,
|
||||
Slug = link.Slug,
|
||||
DestinationUrl = link.DestinationUrl,
|
||||
Title = link?.Title,
|
||||
Status = link.Status.ToString(),
|
||||
ClickCount = 0,
|
||||
CreatedAt = link.CreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new BulkCreateLinksResponse
|
||||
{
|
||||
Created = created,
|
||||
Errors = errors
|
||||
};
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
|
||||
private static string GenerateSlug()
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var random = new Random();
|
||||
return new string(Enumerable.Repeat(chars, 7).Select(s => s[random.Next(s.Length)]).ToArray());
|
||||
}
|
||||
|
||||
private static int? GetPlanLinkLimit(WorkspacePlan? plan)
|
||||
{
|
||||
return plan switch
|
||||
{
|
||||
WorkspacePlan.Business => null, // Unlimited
|
||||
WorkspacePlan.Pro => 10000,
|
||||
_ => 100 // Free plan
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class CreateLinkRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public string DestinationUrl { get; set; } = string.Empty;
|
||||
public string? Slug { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
}
|
||||
|
||||
public class CreateLinkValidator : Validator<CreateLinkRequest>
|
||||
{
|
||||
public CreateLinkValidator()
|
||||
{
|
||||
RuleFor(x => x.DestinationUrl)
|
||||
.NotEmpty().WithMessage("Destination URL is required")
|
||||
.MaximumLength(2048).WithMessage("Destination URL must not exceed 2048 characters")
|
||||
.Must(BeAValidUrl).WithMessage("Destination URL must be a valid URL");
|
||||
|
||||
RuleFor(x => x.Slug)
|
||||
.MaximumLength(50).WithMessage("Slug must not exceed 50 characters")
|
||||
.Matches(@"^[a-zA-Z0-9_-]*$")
|
||||
.WithMessage("Slug can only contain letters, numbers, hyphens, and underscores")
|
||||
.When(x => !string.IsNullOrEmpty(x.Slug));
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
.MaximumLength(255).WithMessage("Title must not exceed 255 characters");
|
||||
}
|
||||
|
||||
private static bool BeAValidUrl(string url)
|
||||
{
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<CreateLinkRequest, LinkResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/links");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateLinkRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateLinkAsync(req.WorkspaceId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Link limit reached. Please upgrade your plan to create more links."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify project belongs to workspace if specified
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
var projectExists = await db.Projects
|
||||
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (!projectExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate or validate slug
|
||||
var slug = req.Slug;
|
||||
if (string.IsNullOrEmpty(slug))
|
||||
{
|
||||
// Auto-generate unique slug
|
||||
do
|
||||
{
|
||||
slug = SlugGenerator.Generate();
|
||||
} while (await db.ShortLinks.AnyAsync(l => l.Slug == slug && l.DomainId == null, ct));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if custom slug is already taken (on default domain)
|
||||
var slugExists = await db.ShortLinks
|
||||
.AnyAsync(l => l.Slug == slug && l.DomainId == null, ct);
|
||||
|
||||
if (slugExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var link = new ShortLink
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
ProjectId = req.ProjectId,
|
||||
DomainId = null, // Default domain for now
|
||||
Slug = slug,
|
||||
DestinationUrl = req.DestinationUrl,
|
||||
Title = req.Title,
|
||||
Status = ShortLinkStatus.Active,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
db.ShortLinks.Add(link);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new LinkResponse(
|
||||
link.Id,
|
||||
link.WorkspaceId,
|
||||
link.ProjectId,
|
||||
link.DomainId,
|
||||
link.Slug,
|
||||
link.DestinationUrl,
|
||||
link.Title,
|
||||
link.Status.ToString(),
|
||||
link.ExpiresAt,
|
||||
link.PasswordHash != null,
|
||||
link.CreatedAt,
|
||||
link.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class DeleteLinkRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteLinkEndpoint(AppDbContext db)
|
||||
: Endpoint<DeleteLinkRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/links/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteLinkRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Include(l => l.Workspace)
|
||||
.FirstOrDefaultAsync(l =>
|
||||
l.Id == req.Id &&
|
||||
l.WorkspaceId == req.WorkspaceId &&
|
||||
l.Workspace.OwnerUserId == userId &&
|
||||
l.DeletedAt == null, ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
link.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class GetLinkRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class GetLinkEndpoint(AppDbContext db)
|
||||
: Endpoint<GetLinkRequest, LinkResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/links/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetLinkRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId &&
|
||||
l.DeletedAt == null)
|
||||
.Select(l => new LinkResponse(
|
||||
l.Id,
|
||||
l.WorkspaceId,
|
||||
l.ProjectId,
|
||||
l.DomainId,
|
||||
l.Slug,
|
||||
l.DestinationUrl,
|
||||
l.Title,
|
||||
l.Status.ToString(),
|
||||
l.ExpiresAt,
|
||||
l.PasswordHash != null,
|
||||
l.CreatedAt,
|
||||
l.UpdatedAt,
|
||||
l.DeletedAt
|
||||
))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(link, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class ListLinksRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool IncludeDeleted { get; set; } = false;
|
||||
}
|
||||
|
||||
public class ListLinksEndpoint(AppDbContext db)
|
||||
: Endpoint<ListLinksRequest, LinkListResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/links");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListLinksRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var query = db.ShortLinks
|
||||
.Where(l => l.WorkspaceId == req.WorkspaceId);
|
||||
|
||||
// Filter by deleted status (exclude soft-deleted by default)
|
||||
if (!req.IncludeDeleted) query = query.Where(l => l.DeletedAt == null);
|
||||
|
||||
// Filter by project if specified
|
||||
if (req.ProjectId.HasValue) query = query.Where(l => l.ProjectId == req.ProjectId.Value);
|
||||
|
||||
// Filter by status if specified
|
||||
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
|
||||
query = query.Where(l => l.Status == status);
|
||||
|
||||
var links = await query
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.Select(l => new LinkResponse(
|
||||
l.Id,
|
||||
l.WorkspaceId,
|
||||
l.ProjectId,
|
||||
l.DomainId,
|
||||
l.Slug,
|
||||
l.DestinationUrl,
|
||||
l.Title,
|
||||
l.Status.ToString(),
|
||||
l.ExpiresAt,
|
||||
l.PasswordHash != null,
|
||||
l.CreatedAt,
|
||||
l.UpdatedAt,
|
||||
l.DeletedAt
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new LinkListResponse(links), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class RestoreLinkRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class RestoreLinkEndpoint(AppDbContext db)
|
||||
: Endpoint<RestoreLinkRequest, LinkResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/links/{Id}/restore");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RestoreLinkRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Include(l => l.Workspace)
|
||||
.FirstOrDefaultAsync(l =>
|
||||
l.Id == req.Id &&
|
||||
l.WorkspaceId == req.WorkspaceId &&
|
||||
l.Workspace.OwnerUserId == userId &&
|
||||
l.DeletedAt != null, ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Deleted link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the link
|
||||
link.DeletedAt = null;
|
||||
link.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new LinkResponse(
|
||||
link.Id,
|
||||
link.WorkspaceId,
|
||||
link.ProjectId,
|
||||
link.DomainId,
|
||||
link.Slug,
|
||||
link.DestinationUrl,
|
||||
link.Title,
|
||||
link.Status.ToString(),
|
||||
link.ExpiresAt,
|
||||
link.PasswordHash != null,
|
||||
link.CreatedAt,
|
||||
link.UpdatedAt,
|
||||
link.DeletedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class UpdateLinkRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string? DestinationUrl { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public bool? RemovePassword { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public bool? RemoveProject { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateLinkValidator : Validator<UpdateLinkRequest>
|
||||
{
|
||||
public UpdateLinkValidator()
|
||||
{
|
||||
RuleFor(x => x.DestinationUrl)
|
||||
.MaximumLength(2048).WithMessage("Destination URL must not exceed 2048 characters")
|
||||
.Must(BeAValidUrl).WithMessage("Destination URL must be a valid URL")
|
||||
.When(x => !string.IsNullOrEmpty(x.DestinationUrl));
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
.MaximumLength(255).WithMessage("Title must not exceed 255 characters");
|
||||
|
||||
RuleFor(x => x.Status)
|
||||
.Must(s => s == null || Enum.TryParse<ShortLinkStatus>(s, true, out _))
|
||||
.WithMessage("Status must be 'Active' or 'Disabled'");
|
||||
|
||||
RuleFor(x => x.Password)
|
||||
.MinimumLength(4).WithMessage("Password must be at least 4 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Password));
|
||||
}
|
||||
|
||||
private static bool BeAValidUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return true;
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateLinkEndpoint(AppDbContext db)
|
||||
: Endpoint<UpdateLinkRequest, LinkResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/workspaces/{WorkspaceId}/links/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateLinkRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Include(l => l.Workspace)
|
||||
.FirstOrDefaultAsync(
|
||||
l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify project belongs to workspace if specified
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
var projectExists = await db.Projects
|
||||
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (!projectExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (!string.IsNullOrEmpty(req.DestinationUrl)) link.DestinationUrl = req.DestinationUrl;
|
||||
|
||||
if (req.Title != null) link.Title = req.Title;
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
|
||||
link.Status = status;
|
||||
|
||||
if (req.ExpiresAt.HasValue) link.ExpiresAt = req.ExpiresAt.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Password))
|
||||
link.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password);
|
||||
else if (req.RemovePassword == true) link.PasswordHash = null;
|
||||
|
||||
if (req.ProjectId.HasValue)
|
||||
link.ProjectId = req.ProjectId.Value;
|
||||
else if (req.RemoveProject == true) link.ProjectId = null;
|
||||
|
||||
link.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new LinkResponse(
|
||||
link.Id,
|
||||
link.WorkspaceId,
|
||||
link.ProjectId,
|
||||
link.DomainId,
|
||||
link.Slug,
|
||||
link.DestinationUrl,
|
||||
link.Title,
|
||||
link.Status.ToString(),
|
||||
link.ExpiresAt,
|
||||
link.PasswordHash != null,
|
||||
link.CreatedAt,
|
||||
link.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
|
||||
namespace TrackQrApi.Features.Plans.Endpoints;
|
||||
|
||||
public class GetUsageRequest
|
||||
{
|
||||
public Guid? WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public record UsageResponse(
|
||||
int Workspaces,
|
||||
int Links,
|
||||
int QRCodes,
|
||||
int Domains,
|
||||
int EventsThisMonth,
|
||||
string Plan,
|
||||
LimitsResponse Limits
|
||||
);
|
||||
|
||||
public record LimitsResponse(
|
||||
int MaxWorkspaces,
|
||||
int MaxLinks,
|
||||
int MaxQRCodes,
|
||||
int MaxDomains,
|
||||
int MaxEventsPerMonth,
|
||||
bool HasCustomDomains,
|
||||
bool HasPasswordProtection
|
||||
);
|
||||
|
||||
public class GetUsageEndpoint(IPlanLimitsService planLimits)
|
||||
: Endpoint<GetUsageRequest, UsageResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/usage");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetUsageRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
if (req.WorkspaceId.HasValue)
|
||||
{
|
||||
var wsUsage = await planLimits.GetWorkspaceUsageAsync(req.WorkspaceId.Value, ct);
|
||||
|
||||
var response = new UsageResponse(
|
||||
1,
|
||||
wsUsage.Links,
|
||||
wsUsage.QRCodes,
|
||||
wsUsage.Domains,
|
||||
wsUsage.EventsThisMonth,
|
||||
wsUsage.Plan.ToString(),
|
||||
new LimitsResponse(
|
||||
wsUsage.Limits.MaxWorkspaces,
|
||||
wsUsage.Limits.MaxLinksPerWorkspace,
|
||||
wsUsage.Limits.MaxQRCodesPerWorkspace,
|
||||
wsUsage.Limits.MaxDomainsPerWorkspace,
|
||||
wsUsage.Limits.MaxEventsPerMonth,
|
||||
wsUsage.Limits.HasCustomDomains,
|
||||
wsUsage.Limits.HasPasswordProtection
|
||||
)
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var usage = await planLimits.GetUsageAsync(userId, ct);
|
||||
var limits = planLimits.GetLimits(usage.HighestPlan);
|
||||
|
||||
var response = new UsageResponse(
|
||||
usage.TotalWorkspaces,
|
||||
usage.TotalLinks,
|
||||
usage.TotalQRCodes,
|
||||
usage.TotalDomains,
|
||||
usage.EventsThisMonth,
|
||||
usage.HighestPlan.ToString(),
|
||||
new LimitsResponse(
|
||||
limits.MaxWorkspaces,
|
||||
limits.MaxLinksPerWorkspace,
|
||||
limits.MaxQRCodesPerWorkspace,
|
||||
limits.MaxDomainsPerWorkspace,
|
||||
limits.MaxEventsPerMonth,
|
||||
limits.HasCustomDomains,
|
||||
limits.HasPasswordProtection
|
||||
)
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Plans.Services;
|
||||
|
||||
public interface IPlanLimitsService
|
||||
{
|
||||
PlanLimits GetLimits(WorkspacePlan plan);
|
||||
Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public record PlanLimits(
|
||||
int MaxWorkspaces,
|
||||
int MaxLinksPerWorkspace,
|
||||
int MaxQRCodesPerWorkspace,
|
||||
int MaxDomainsPerWorkspace,
|
||||
int MaxEventsPerMonth,
|
||||
bool HasCustomDomains,
|
||||
bool HasPasswordProtection,
|
||||
bool HasAnalytics
|
||||
);
|
||||
|
||||
public record UsageStats(
|
||||
int TotalWorkspaces,
|
||||
int TotalLinks,
|
||||
int TotalQRCodes,
|
||||
int TotalDomains,
|
||||
int EventsThisMonth,
|
||||
WorkspacePlan HighestPlan
|
||||
);
|
||||
|
||||
public record WorkspaceUsageStats(
|
||||
Guid WorkspaceId,
|
||||
WorkspacePlan Plan,
|
||||
int Links,
|
||||
int QRCodes,
|
||||
int Domains,
|
||||
int EventsThisMonth,
|
||||
PlanLimits Limits
|
||||
);
|
||||
|
||||
public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsService
|
||||
{
|
||||
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
|
||||
{
|
||||
[WorkspacePlan.Free] = new PlanLimits(
|
||||
1,
|
||||
50,
|
||||
25,
|
||||
0,
|
||||
10_000,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
),
|
||||
[WorkspacePlan.Pro] = new PlanLimits(
|
||||
5,
|
||||
5_000,
|
||||
1_000,
|
||||
3,
|
||||
100_000,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
),
|
||||
[WorkspacePlan.Business] = new PlanLimits(
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
};
|
||||
|
||||
public PlanLimits GetLimits(WorkspacePlan plan)
|
||||
{
|
||||
return PlanConfigs[plan];
|
||||
}
|
||||
|
||||
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspaces = await db.Workspaces
|
||||
.Where(w => w.OwnerUserId == userId)
|
||||
.Select(w => new { w.Id, w.Plan })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var workspaceIds = workspaces.Select(w => w.Id).ToList();
|
||||
|
||||
var totalLinks = await db.ShortLinks
|
||||
.CountAsync(l => workspaceIds.Contains(l.WorkspaceId), ct);
|
||||
|
||||
var totalQRCodes = await db.QrCodeDesigns
|
||||
.CountAsync(q => workspaceIds.Contains(q.WorkspaceId), ct);
|
||||
|
||||
var totalDomains = await db.Domains
|
||||
.CountAsync(d => workspaceIds.Contains(d.WorkspaceId), ct);
|
||||
|
||||
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var eventsThisMonth = await db.Events
|
||||
.CountAsync(e => workspaceIds.Contains(e.WorkspaceId) && e.Timestamp >= monthStart, ct);
|
||||
|
||||
var highestPlan = workspaces.Any()
|
||||
? workspaces.Max(w => w.Plan)
|
||||
: WorkspacePlan.Free;
|
||||
|
||||
return new UsageStats(
|
||||
workspaces.Count,
|
||||
totalLinks,
|
||||
totalQRCodes,
|
||||
totalDomains,
|
||||
eventsThisMonth,
|
||||
highestPlan
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.Where(w => w.Id == workspaceId)
|
||||
.Select(w => new { w.Id, w.Plan })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (workspace == null)
|
||||
throw new KeyNotFoundException("Workspace not found");
|
||||
|
||||
var links = await db.ShortLinks.CountAsync(l => l.WorkspaceId == workspaceId, ct);
|
||||
var qrCodes = await db.QrCodeDesigns.CountAsync(q => q.WorkspaceId == workspaceId, ct);
|
||||
var domains = await db.Domains.CountAsync(d => d.WorkspaceId == workspaceId, ct);
|
||||
|
||||
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var eventsThisMonth = await db.Events
|
||||
.CountAsync(e => e.WorkspaceId == workspaceId && e.Timestamp >= monthStart, ct);
|
||||
|
||||
var limits = GetLimits(workspace.Plan);
|
||||
|
||||
return new WorkspaceUsageStats(
|
||||
workspaceId,
|
||||
workspace.Plan,
|
||||
links,
|
||||
qrCodes,
|
||||
domains,
|
||||
eventsThisMonth,
|
||||
limits
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetUsageAsync(userId, ct);
|
||||
var limits = GetLimits(usage.HighestPlan);
|
||||
return usage.TotalWorkspaces < limits.MaxWorkspaces;
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.Links < usage.Limits.MaxLinksPerWorkspace;
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.QRCodes < usage.Limits.MaxQRCodesPerWorkspace;
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.Domains < usage.Limits.MaxDomainsPerWorkspace;
|
||||
}
|
||||
|
||||
public async Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TrackQrApi.Features.Projects.Common;
|
||||
|
||||
public record ProjectResponse(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
string? Description,
|
||||
int LinkCount,
|
||||
int QRCodeCount,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record ProjectListResponse(
|
||||
IEnumerable<ProjectResponse> Projects
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class CreateProjectRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class CreateProjectValidator : Validator<CreateProjectRequest>
|
||||
{
|
||||
public CreateProjectValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required")
|
||||
.MaximumLength(100).WithMessage("Name must not exceed 100 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateProjectEndpoint(AppDbContext db)
|
||||
: Endpoint<CreateProjectRequest, ProjectResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/projects");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateProjectRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Name = req.Name,
|
||||
Description = req.Description,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Projects.Add(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new ProjectResponse(
|
||||
project.Id,
|
||||
project.WorkspaceId,
|
||||
project.Name,
|
||||
project.Description,
|
||||
0, // LinkCount - new project has no links
|
||||
0, // QRCodeCount - new project has no QR codes
|
||||
project.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class DeleteProjectRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteProjectEndpoint(AppDbContext db)
|
||||
: Endpoint<DeleteProjectRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/projects/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteProjectRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var project = await db.Projects
|
||||
.Include(p => p.Workspace)
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (project is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
db.Projects.Remove(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class GetProjectRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class GetProjectEndpoint(AppDbContext db)
|
||||
: Endpoint<GetProjectRequest, ProjectResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/projects/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetProjectRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var project = await db.Projects
|
||||
.Where(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId)
|
||||
.Select(p => new ProjectResponse(
|
||||
p.Id,
|
||||
p.WorkspaceId,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.ShortLinks.Count(l => l.DeletedAt == null),
|
||||
p.QRCodeDesigns.Count,
|
||||
p.CreatedAt
|
||||
))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (project is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(project, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class ListProjectsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public class ListProjectsEndpoint(AppDbContext db)
|
||||
: Endpoint<ListProjectsRequest, ProjectListResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/projects");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListProjectsRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var projects = await db.Projects
|
||||
.Where(p => p.WorkspaceId == req.WorkspaceId)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Select(p => new ProjectResponse(
|
||||
p.Id,
|
||||
p.WorkspaceId,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.ShortLinks.Count(l => l.DeletedAt == null),
|
||||
p.QRCodeDesigns.Count,
|
||||
p.CreatedAt
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class UpdateProjectRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProjectValidator : Validator<UpdateProjectRequest>
|
||||
{
|
||||
public UpdateProjectValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required")
|
||||
.MaximumLength(100).WithMessage("Name must not exceed 100 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateProjectEndpoint(AppDbContext db)
|
||||
: Endpoint<UpdateProjectRequest, ProjectResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/workspaces/{WorkspaceId}/projects/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateProjectRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var project = await db.Projects
|
||||
.Include(p => p.Workspace)
|
||||
.Include(p => p.ShortLinks)
|
||||
.Include(p => p.QRCodeDesigns)
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (project is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
project.Name = req.Name;
|
||||
project.Description = req.Description;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new ProjectResponse(
|
||||
project.Id,
|
||||
project.WorkspaceId,
|
||||
project.Name,
|
||||
project.Description,
|
||||
project.ShortLinks.Count(l => l.DeletedAt == null),
|
||||
project.QRCodeDesigns.Count,
|
||||
project.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
/// <summary>
|
||||
/// QR code style configuration stored as JSON
|
||||
/// </summary>
|
||||
public class QRCodeStyle
|
||||
{
|
||||
/// <summary>Foreground color in hex format (e.g., "#000000")</summary>
|
||||
public string ForegroundColor { get; set; } = "#000000";
|
||||
|
||||
/// <summary>Background color in hex format (e.g., "#FFFFFF")</summary>
|
||||
public string BackgroundColor { get; set; } = "#FFFFFF";
|
||||
|
||||
/// <summary>Error correction level: L (7%), M (15%), Q (25%), H (30%)</summary>
|
||||
public string ErrorCorrectionLevel { get; set; } = "M";
|
||||
|
||||
/// <summary>Quiet zone (margin) in modules</summary>
|
||||
public int QuietZone { get; set; } = 4;
|
||||
|
||||
/// <summary>Module shape: Square, Circle, Rounded</summary>
|
||||
public string ModuleShape { get; set; } = "Square";
|
||||
|
||||
/// <summary>Eye shape: Square, Circle, Rounded</summary>
|
||||
public string EyeShape { get; set; } = "Square";
|
||||
|
||||
/// <summary>Pixels per module for rendering</summary>
|
||||
public int PixelsPerModule { get; set; } = 20;
|
||||
}
|
||||
|
||||
public record QRCodeResponse(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid? ProjectId,
|
||||
Guid? ShortLinkId,
|
||||
string? ShortLinkSlug,
|
||||
string Name,
|
||||
QRCodeStyle Style,
|
||||
Guid? LogoAssetId,
|
||||
string? LogoUrl,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
);
|
||||
|
||||
public record QRCodeListResponse(
|
||||
IEnumerable<QRCodeResponse> QRCodes
|
||||
);
|
||||
|
||||
public record QRCodePreviewResponse(
|
||||
string DataUrl,
|
||||
string Format,
|
||||
int Width,
|
||||
int Height
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class CreateQRCodeRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public Guid? ShortLinkId { get; set; }
|
||||
public Guid? LogoAssetId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public QRCodeStyle? Style { get; set; }
|
||||
}
|
||||
|
||||
public class CreateQRCodeValidator : Validator<CreateQRCodeRequest>
|
||||
{
|
||||
public CreateQRCodeValidator()
|
||||
{
|
||||
RuleFor(x => x.ShortLinkId)
|
||||
.NotEmpty().WithMessage("ShortLinkId is required");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<CreateQRCodeRequest, QRCodeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/qrcodes");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateQRCodeRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateQRCodeAsync(req.WorkspaceId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("QR code limit reached. Please upgrade your plan to create more QR codes."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify short link belongs to workspace
|
||||
string? linkSlug = null;
|
||||
if (req.ShortLinkId.HasValue)
|
||||
{
|
||||
var link = await db.ShortLinks
|
||||
.Where(l => l.Id == req.ShortLinkId.Value && l.WorkspaceId == req.WorkspaceId)
|
||||
.Select(l => new { l.Slug })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
linkSlug = link.Slug;
|
||||
}
|
||||
|
||||
// Verify project belongs to workspace if specified
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
var projectExists = await db.Projects
|
||||
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (!projectExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify logo asset belongs to workspace if specified
|
||||
string? logoUrl = null;
|
||||
if (req.LogoAssetId.HasValue)
|
||||
{
|
||||
var asset = await db.Assets
|
||||
.Where(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId)
|
||||
.Select(a => new { a.StorageKey })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
logoUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/assets/{asset.StorageKey}";
|
||||
}
|
||||
|
||||
var style = req.Style ?? new QRCodeStyle();
|
||||
var name = req.Name ?? $"QR Code {DateTime.UtcNow:yyyy-MM-dd}";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var qrCode = new QRCodeDesign
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
ProjectId = req.ProjectId,
|
||||
ShortLinkId = req.ShortLinkId,
|
||||
Name = name,
|
||||
StyleJson = JsonSerializer.Serialize(style),
|
||||
LogoAssetId = req.LogoAssetId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
db.QrCodeDesigns.Add(qrCode);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new QRCodeResponse(
|
||||
qrCode.Id,
|
||||
qrCode.WorkspaceId,
|
||||
qrCode.ProjectId,
|
||||
qrCode.ShortLinkId,
|
||||
linkSlug,
|
||||
qrCode.Name,
|
||||
style,
|
||||
qrCode.LogoAssetId,
|
||||
logoUrl,
|
||||
qrCode.CreatedAt,
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class DeleteQRCodeRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteQRCodeEndpoint(AppDbContext db)
|
||||
: Endpoint<DeleteQRCodeRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/qrcodes/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteQRCodeRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.Workspace)
|
||||
.FirstOrDefaultAsync(
|
||||
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (qrCode is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
db.QrCodeDesigns.Remove(qrCode);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
using TrackQrApi.Features.QRCodes.Services;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class ExportQRCodeRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string? Format { get; set; } // png or svg
|
||||
public int? Size { get; set; }
|
||||
}
|
||||
|
||||
public class ExportQRCodeEndpoint(
|
||||
AppDbContext db,
|
||||
IQrCodeGeneratorService qrGenerator,
|
||||
IAssetStorageService assetStorage)
|
||||
: Endpoint<ExportQRCodeRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/export");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ExportQRCodeRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (qrCode is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrCode.ShortLink is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
|
||||
var format = (req.Format ?? "png").ToLowerInvariant();
|
||||
var size = req.Size ?? 512;
|
||||
|
||||
// Build the short link URL with QR tracking param
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}?qr={qrCode.Id}";
|
||||
|
||||
var filename = $"qrcode-{qrCode.ShortLink.Slug}";
|
||||
|
||||
// Load logo if available
|
||||
Stream? logoStream = null;
|
||||
if (qrCode.LogoAsset != null)
|
||||
{
|
||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (format == "svg")
|
||||
{
|
||||
// SVG doesn't support logo overlay currently
|
||||
var svg = qrGenerator.GenerateSvg(linkUrl, style, size);
|
||||
HttpContext.Response.ContentType = "image/svg+xml";
|
||||
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\"";
|
||||
await HttpContext.Response.WriteAsync(svg, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var png = qrGenerator.GeneratePng(linkUrl, style, size, logoStream);
|
||||
HttpContext.Response.ContentType = "image/png";
|
||||
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\"";
|
||||
await HttpContext.Response.Body.WriteAsync(png, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
logoStream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class GetQRCodeAnalyticsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string Period { get; set; } = "7d";
|
||||
}
|
||||
|
||||
public record QRCodeAnalyticsSummary(
|
||||
int TotalScans,
|
||||
int UniqueVisitors
|
||||
);
|
||||
|
||||
public record QRCodeTimeSeriesPoint(
|
||||
string Date,
|
||||
int Scans
|
||||
);
|
||||
|
||||
public record QRCodeAnalyticsResponse(
|
||||
Guid QRCodeId,
|
||||
string Name,
|
||||
string? LinkSlug,
|
||||
QRCodeAnalyticsSummary Summary,
|
||||
List<QRCodeTimeSeriesPoint> TimeSeries,
|
||||
Dictionary<string, int> DeviceBreakdown,
|
||||
Dictionary<string, int> ReferrerBreakdown,
|
||||
Dictionary<string, int> CountryBreakdown
|
||||
);
|
||||
|
||||
public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
|
||||
: Endpoint<GetQRCodeAnalyticsRequest, QRCodeAnalyticsResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/analytics");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetQRCodeAnalyticsRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (qrCode == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var startDate = GetStartDate(req.Period);
|
||||
|
||||
var events = await db.Events
|
||||
.Where(e => e.QRCodeId == req.Id && e.Type == EventType.Scan && e.Timestamp >= startDate)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var summary = new QRCodeAnalyticsSummary(
|
||||
events.Count,
|
||||
events.Select(e => e.IpHash).Distinct().Count()
|
||||
);
|
||||
|
||||
var timeSeries = events
|
||||
.GroupBy(e => e.Timestamp.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new QRCodeTimeSeriesPoint(
|
||||
g.Key.ToString("yyyy-MM-dd"),
|
||||
g.Count()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var deviceBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
|
||||
.GroupBy(e => e.DeviceType!)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var referrerBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.Referrer))
|
||||
.GroupBy(e => GetReferrerDomain(e.Referrer!))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var countryBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
|
||||
.GroupBy(e => e.CountryCode!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var response = new QRCodeAnalyticsResponse(
|
||||
qrCode.Id,
|
||||
qrCode.Name,
|
||||
qrCode.ShortLink?.Slug,
|
||||
summary,
|
||||
timeSeries,
|
||||
deviceBreakdown,
|
||||
referrerBreakdown,
|
||||
countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
|
||||
private static DateTime GetStartDate(string period)
|
||||
{
|
||||
return period switch
|
||||
{
|
||||
"24h" => DateTime.UtcNow.AddHours(-24),
|
||||
"7d" => DateTime.UtcNow.AddDays(-7),
|
||||
"30d" => DateTime.UtcNow.AddDays(-30),
|
||||
"all" => DateTime.MinValue,
|
||||
_ => DateTime.UtcNow.AddDays(-7)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetReferrerDomain(string referrer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(referrer);
|
||||
return uri.Host;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return referrer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class GetQRCodeRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class GetQRCodeEndpoint(AppDbContext db)
|
||||
: Endpoint<GetQRCodeRequest, QRCodeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetQRCodeRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (qrCode is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
|
||||
var response = new QRCodeResponse(
|
||||
qrCode.Id,
|
||||
qrCode.WorkspaceId,
|
||||
qrCode.ProjectId,
|
||||
qrCode.ShortLinkId,
|
||||
qrCode.ShortLink?.Slug,
|
||||
qrCode.Name,
|
||||
style,
|
||||
qrCode.LogoAssetId,
|
||||
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
|
||||
qrCode.CreatedAt,
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class ListQRCodesRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public Guid? ShortLinkId { get; set; }
|
||||
}
|
||||
|
||||
public class ListQRCodesEndpoint(AppDbContext db)
|
||||
: Endpoint<ListQRCodesRequest, QRCodeListResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/qrcodes");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListQRCodesRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var query = db.QrCodeDesigns
|
||||
.Where(q => q.WorkspaceId == req.WorkspaceId);
|
||||
|
||||
if (req.ProjectId.HasValue) query = query.Where(q => q.ProjectId == req.ProjectId.Value);
|
||||
|
||||
if (req.ShortLinkId.HasValue) query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
|
||||
|
||||
var qrCodes = await query
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.OrderByDescending(q => q.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
|
||||
var response = new QRCodeListResponse(
|
||||
qrCodes.Select(q => new QRCodeResponse(
|
||||
q.Id,
|
||||
q.WorkspaceId,
|
||||
q.ProjectId,
|
||||
q.ShortLinkId,
|
||||
q.ShortLink?.Slug,
|
||||
q.Name,
|
||||
JsonSerializer.Deserialize<QRCodeStyle>(q.StyleJson) ?? new QRCodeStyle(),
|
||||
q.LogoAssetId,
|
||||
q.LogoAsset != null ? $"{baseUrl}/assets/{q.LogoAsset.StorageKey}" : null,
|
||||
q.CreatedAt,
|
||||
q.UpdatedAt
|
||||
))
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
using TrackQrApi.Features.QRCodes.Services;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class PreviewQRCodeRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public int? Size { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewQRCodeEndpoint(
|
||||
AppDbContext db,
|
||||
IQrCodeGeneratorService qrGenerator,
|
||||
IAssetStorageService assetStorage)
|
||||
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/preview");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PreviewQRCodeRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (qrCode is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrCode.ShortLink is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
|
||||
var size = req.Size ?? 256;
|
||||
|
||||
// Build the short link URL
|
||||
// TODO: Use actual domain configuration
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
|
||||
|
||||
// Load logo if available
|
||||
Stream? logoStream = null;
|
||||
if (qrCode.LogoAsset != null)
|
||||
{
|
||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
|
||||
|
||||
var response = new QRCodePreviewResponse(
|
||||
dataUrl,
|
||||
"png",
|
||||
size,
|
||||
size
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
logoStream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class UpdateQRCodeRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public bool? RemoveProject { get; set; }
|
||||
public Guid? LogoAssetId { get; set; }
|
||||
public bool? RemoveLogo { get; set; }
|
||||
public QRCodeStyle? Style { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
: Endpoint<UpdateQRCodeRequest, QRCodeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/workspaces/{WorkspaceId}/qrcodes/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateQRCodeRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.Workspace)
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.FirstOrDefaultAsync(
|
||||
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (qrCode is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify project belongs to workspace if specified
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
var projectExists = await db.Projects
|
||||
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (!projectExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
qrCode.ProjectId = req.ProjectId.Value;
|
||||
}
|
||||
else if (req.RemoveProject == true)
|
||||
{
|
||||
qrCode.ProjectId = null;
|
||||
}
|
||||
|
||||
// Update name if provided
|
||||
if (!string.IsNullOrWhiteSpace(req.Name)) qrCode.Name = req.Name;
|
||||
|
||||
// Handle logo asset update
|
||||
if (req.LogoAssetId.HasValue)
|
||||
{
|
||||
var assetExists = await db.Assets
|
||||
.AnyAsync(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (!assetExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
qrCode.LogoAssetId = req.LogoAssetId.Value;
|
||||
// Reload the asset for the response
|
||||
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
|
||||
}
|
||||
else if (req.RemoveLogo == true)
|
||||
{
|
||||
qrCode.LogoAssetId = null;
|
||||
qrCode.LogoAsset = null;
|
||||
}
|
||||
|
||||
if (req.Style != null) qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
|
||||
|
||||
qrCode.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
|
||||
var response = new QRCodeResponse(
|
||||
qrCode.Id,
|
||||
qrCode.WorkspaceId,
|
||||
qrCode.ProjectId,
|
||||
qrCode.ShortLinkId,
|
||||
qrCode.ShortLink?.Slug,
|
||||
qrCode.Name,
|
||||
style,
|
||||
qrCode.LogoAssetId,
|
||||
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
|
||||
qrCode.CreatedAt,
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Text;
|
||||
using QRCoder;
|
||||
using SkiaSharp;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace TrackQrApi.Features.QRCodes.Services;
|
||||
|
||||
public interface IQrCodeGeneratorService
|
||||
{
|
||||
byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null);
|
||||
string GenerateSvg(string content, QRCodeStyle style, int size = 512);
|
||||
string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null);
|
||||
}
|
||||
|
||||
public class QrCodeGeneratorService : IQrCodeGeneratorService
|
||||
{
|
||||
public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null)
|
||||
{
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
||||
|
||||
var moduleMatrix = qrCodeData.ModuleMatrix;
|
||||
var moduleCount = moduleMatrix.Count;
|
||||
|
||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||
var totalModules = moduleCount + style.QuietZone * 2;
|
||||
var pixelsPerModule = Math.Max(4, size / totalModules);
|
||||
var actualSize = totalModules * pixelsPerModule;
|
||||
|
||||
// Create bitmap with SkiaSharp for custom shapes
|
||||
var foregroundColor = ParseSkColor(style.ForegroundColor);
|
||||
var backgroundColor = ParseSkColor(style.BackgroundColor);
|
||||
|
||||
using var surface = SKSurface.Create(new SKImageInfo(actualSize, actualSize));
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
// Draw background
|
||||
canvas.Clear(backgroundColor);
|
||||
|
||||
// Draw QR modules with custom shapes
|
||||
var modulePaint = new SKPaint
|
||||
{
|
||||
Color = foregroundColor,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||
|
||||
for (var y = 0; y < moduleCount; y++)
|
||||
for (var x = 0; x < moduleCount; x++)
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
var px = quietZoneOffset + x * pixelsPerModule;
|
||||
var py = quietZoneOffset + y * pixelsPerModule;
|
||||
|
||||
// Check if this is part of a finder pattern (eyes)
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
|
||||
if (isEye)
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
|
||||
else
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
|
||||
}
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
var qrBytes = data.ToArray();
|
||||
|
||||
// If no logo, return the QR code as-is
|
||||
if (logoStream == null) return qrBytes;
|
||||
|
||||
// Overlay logo on QR code
|
||||
return OverlayLogo(qrBytes, logoStream, actualSize);
|
||||
}
|
||||
|
||||
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
|
||||
{
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
||||
|
||||
var moduleMatrix = qrCodeData.ModuleMatrix;
|
||||
var moduleCount = moduleMatrix.Count;
|
||||
|
||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||
var totalModules = moduleCount + style.QuietZone * 2;
|
||||
var pixelsPerModule = (float)size / totalModules;
|
||||
var actualSize = size;
|
||||
|
||||
var foreground = style.ForegroundColor;
|
||||
var background = style.BackgroundColor;
|
||||
|
||||
var svg = new StringBuilder();
|
||||
svg.AppendLine(
|
||||
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
|
||||
svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
|
||||
|
||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||
|
||||
for (var y = 0; y < moduleCount; y++)
|
||||
for (var x = 0; x < moduleCount; x++)
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
var px = quietZoneOffset + x * pixelsPerModule;
|
||||
var py = quietZoneOffset + y * pixelsPerModule;
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
var shape = isEye ? style.EyeShape : style.ModuleShape;
|
||||
|
||||
var padding = pixelsPerModule * 0.1f;
|
||||
var moduleSize = pixelsPerModule - padding;
|
||||
|
||||
switch (shape.ToLowerInvariant())
|
||||
{
|
||||
case "circle":
|
||||
case "dots":
|
||||
var radius = moduleSize / 2;
|
||||
var cx = px + pixelsPerModule / 2;
|
||||
var cy = py + pixelsPerModule / 2;
|
||||
svg.AppendLine(
|
||||
$" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "rounded":
|
||||
var cornerRadius = moduleSize * 0.3f;
|
||||
svg.AppendLine(
|
||||
$" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "square":
|
||||
default:
|
||||
svg.AppendLine(
|
||||
$" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
svg.AppendLine("</svg>");
|
||||
return svg.ToString();
|
||||
}
|
||||
|
||||
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
|
||||
{
|
||||
var pngBytes = GeneratePng(content, style, size, logoStream);
|
||||
var base64 = Convert.ToBase64String(pngBytes);
|
||||
return $"data:image/png;base64,{base64}";
|
||||
}
|
||||
|
||||
private static bool IsFinderPattern(int x, int y, int moduleCount)
|
||||
{
|
||||
// Top-left finder pattern: 0-6, 0-6
|
||||
if (x <= 6 && y <= 6) return true;
|
||||
// Top-right finder pattern: moduleCount-7 to moduleCount-1, 0-6
|
||||
if (x >= moduleCount - 7 && y <= 6) return true;
|
||||
// Bottom-left finder pattern: 0-6, moduleCount-7 to moduleCount-1
|
||||
if (x <= 6 && y >= moduleCount - 7) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void DrawModule(SKCanvas canvas, float x, float y, float size, SKPaint paint, string shape)
|
||||
{
|
||||
var padding = size * 0.1f; // 10% padding between modules
|
||||
var moduleSize = size - padding;
|
||||
|
||||
switch (shape.ToLowerInvariant())
|
||||
{
|
||||
case "circle":
|
||||
case "dots":
|
||||
var radius = moduleSize / 2;
|
||||
canvas.DrawCircle(x + size / 2, y + size / 2, radius, paint);
|
||||
break;
|
||||
|
||||
case "rounded":
|
||||
var cornerRadius = moduleSize * 0.3f;
|
||||
var rect = new SKRoundRect(
|
||||
new SKRect(x + padding / 2, y + padding / 2, x + size - padding / 2, y + size - padding / 2),
|
||||
cornerRadius
|
||||
);
|
||||
canvas.DrawRoundRect(rect, paint);
|
||||
break;
|
||||
|
||||
case "square":
|
||||
default:
|
||||
canvas.DrawRect(x + padding / 2, y + padding / 2, moduleSize, moduleSize, paint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
|
||||
{
|
||||
using var qrBitmap = SKBitmap.Decode(qrBytes);
|
||||
using var logoBitmap = SKBitmap.Decode(logoStream);
|
||||
|
||||
if (qrBitmap == null || logoBitmap == null) return qrBytes;
|
||||
|
||||
// Logo should be about 20% of QR code size
|
||||
var logoSize = (int)(qrSize * 0.2);
|
||||
var logoX = (qrBitmap.Width - logoSize) / 2;
|
||||
var logoY = (qrBitmap.Height - logoSize) / 2;
|
||||
|
||||
// Create a new surface to draw on
|
||||
using var surface = SKSurface.Create(new SKImageInfo(qrBitmap.Width, qrBitmap.Height));
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
// Draw QR code
|
||||
canvas.DrawBitmap(qrBitmap, 0, 0);
|
||||
|
||||
// Draw white background circle for logo
|
||||
var circlePaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
var circleRadius = logoSize * 0.6f;
|
||||
canvas.DrawCircle(qrBitmap.Width / 2f, qrBitmap.Height / 2f, circleRadius, circlePaint);
|
||||
|
||||
// Resize and draw logo
|
||||
using var resizedLogo = logoBitmap.Resize(
|
||||
new SKImageInfo(logoSize, logoSize),
|
||||
new SKSamplingOptions(SKCubicResampler.Mitchell));
|
||||
if (resizedLogo != null) canvas.DrawBitmap(resizedLogo, logoX, logoY);
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
|
||||
return data.ToArray();
|
||||
}
|
||||
|
||||
private static QRCodeGenerator.ECCLevel ParseEccLevel(string level)
|
||||
{
|
||||
return level.ToUpperInvariant() switch
|
||||
{
|
||||
"L" => QRCodeGenerator.ECCLevel.L,
|
||||
"M" => QRCodeGenerator.ECCLevel.M,
|
||||
"Q" => QRCodeGenerator.ECCLevel.Q,
|
||||
"H" => QRCodeGenerator.ECCLevel.H,
|
||||
_ => QRCodeGenerator.ECCLevel.M
|
||||
};
|
||||
}
|
||||
|
||||
private static SKColor ParseSkColor(string hexColor)
|
||||
{
|
||||
// Remove # if present
|
||||
var hex = hexColor.TrimStart('#');
|
||||
|
||||
if (hex.Length == 6)
|
||||
{
|
||||
var r = Convert.ToByte(hex[..2], 16);
|
||||
var g = Convert.ToByte(hex[2..4], 16);
|
||||
var b = Convert.ToByte(hex[4..6], 16);
|
||||
return new SKColor(r, g, b);
|
||||
}
|
||||
|
||||
// Default to black
|
||||
return SKColors.Black;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Events.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Redirect.Endpoints;
|
||||
|
||||
public class PasswordRedirectRequest
|
||||
{
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PasswordRedirectResponse
|
||||
{
|
||||
public string Location { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PasswordRedirectValidator : Validator<PasswordRedirectRequest>
|
||||
{
|
||||
public PasswordRedirectValidator()
|
||||
{
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty().WithMessage("Password is required");
|
||||
}
|
||||
}
|
||||
|
||||
public class PasswordRedirectEndpoint(AppDbContext db, IEventTrackingService eventTracking)
|
||||
: Endpoint<PasswordRedirectRequest, PasswordRedirectResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/{Slug}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PasswordRedirectRequest req, CancellationToken ct)
|
||||
{
|
||||
// Look up the short link by slug
|
||||
var link = await db.ShortLinks
|
||||
.Where(l => l.Slug == req.Slug && l.DomainId == null)
|
||||
.Select(l => new
|
||||
{
|
||||
l.Id,
|
||||
l.DestinationUrl,
|
||||
l.Status,
|
||||
l.ExpiresAt,
|
||||
l.PasswordHash,
|
||||
l.WorkspaceId
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Link not found
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Link is disabled
|
||||
if (link.Status == ShortLinkStatus.Disabled)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Link has expired
|
||||
if (link.ExpiresAt.HasValue && link.ExpiresAt.Value < DateTime.UtcNow)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link has expired"), 410, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Link is not password protected - just redirect
|
||||
if (string.IsNullOrEmpty(link.PasswordHash))
|
||||
{
|
||||
// Track click event
|
||||
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
||||
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
|
||||
HttpContext.Response.Headers.Location = link.DestinationUrl;
|
||||
await HttpContext.Response.StartAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.Password, link.PasswordHash))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid password"), 401, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track click event
|
||||
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
||||
|
||||
// Password correct - redirect
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
|
||||
HttpContext.Response.Headers.Location = link.DestinationUrl;
|
||||
await HttpContext.Response.StartAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Events.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Redirect.Endpoints;
|
||||
|
||||
public class RedirectRequest
|
||||
{
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public Guid? Qr { get; set; }
|
||||
}
|
||||
|
||||
public class RedirectResponse
|
||||
{
|
||||
public string Location { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracking)
|
||||
: Endpoint<RedirectRequest, RedirectResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/{Slug}");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("redirect"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RedirectRequest req, CancellationToken ct)
|
||||
{
|
||||
// Look up the short link by slug (default domain = null for now)
|
||||
var link = await db.ShortLinks
|
||||
.Where(l => l.Slug == req.Slug && l.DomainId == null)
|
||||
.Select(l => new
|
||||
{
|
||||
l.Id,
|
||||
l.DestinationUrl,
|
||||
l.Status,
|
||||
l.ExpiresAt,
|
||||
l.PasswordHash,
|
||||
l.WorkspaceId
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Link not found
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Link is disabled
|
||||
if (link.Status == ShortLinkStatus.Disabled)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Link has expired
|
||||
if (link.ExpiresAt.HasValue && link.ExpiresAt.Value < DateTime.UtcNow)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link has expired"), 410, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Link is password protected
|
||||
if (!string.IsNullOrEmpty(link.PasswordHash))
|
||||
{
|
||||
// Return 401 with a hint that password is required
|
||||
// Frontend/client would need to POST the password to verify
|
||||
HttpContext.Response.Headers["X-Password-Required"] = "true";
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Password required"), 401, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track event asynchronously (fire and forget)
|
||||
// If qr parameter is present, track as scan; otherwise track as click
|
||||
if (req.Qr.HasValue)
|
||||
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
|
||||
else
|
||||
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
||||
|
||||
// Redirect to destination (302 Found)
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
|
||||
HttpContext.Response.Headers.Location = link.DestinationUrl;
|
||||
await HttpContext.Response.StartAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
public record WorkspaceResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Plan,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record WorkspaceListResponse(
|
||||
IEnumerable<WorkspaceResponse> Workspaces
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class CreateWorkspaceRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CreateWorkspaceValidator : Validator<CreateWorkspaceRequest>
|
||||
{
|
||||
public CreateWorkspaceValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required")
|
||||
.MaximumLength(100).WithMessage("Name must not exceed 100 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<CreateWorkspaceRequest, WorkspaceResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateWorkspaceRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateWorkspaceAsync(userId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Workspace limit reached. Please upgrade your plan to create more workspaces."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var workspace = new Workspace
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = userId,
|
||||
Name = req.Name,
|
||||
Plan = WorkspacePlan.Free,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Workspaces.Add(workspace);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new WorkspaceResponse(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Plan.ToString(),
|
||||
workspace.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class DeleteWorkspaceRequest
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteWorkspaceEndpoint(AppDbContext db)
|
||||
: Endpoint<DeleteWorkspaceRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteWorkspaceRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.Id && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
db.Workspaces.Remove(workspace);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class GetWorkspaceRequest
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class GetWorkspaceEndpoint(AppDbContext db)
|
||||
: Endpoint<GetWorkspaceRequest, WorkspaceResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetWorkspaceRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.Where(w => w.Id == req.Id && w.OwnerUserId == userId)
|
||||
.Select(w => new WorkspaceResponse(
|
||||
w.Id,
|
||||
w.Name,
|
||||
w.Plan.ToString(),
|
||||
w.CreatedAt
|
||||
))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(workspace, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class ListWorkspacesEndpoint(AppDbContext db)
|
||||
: EndpointWithoutRequest<WorkspaceListResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var workspaces = await db.Workspaces
|
||||
.Where(w => w.OwnerUserId == userId)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.Select(w => new WorkspaceResponse(
|
||||
w.Id,
|
||||
w.Name,
|
||||
w.Plan.ToString(),
|
||||
w.CreatedAt
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new WorkspaceListResponse(workspaces), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Security.Claims;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class UpdateWorkspaceRequest
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class UpdateWorkspaceValidator : Validator<UpdateWorkspaceRequest>
|
||||
{
|
||||
public UpdateWorkspaceValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required")
|
||||
.MaximumLength(100).WithMessage("Name must not exceed 100 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateWorkspaceEndpoint(AppDbContext db)
|
||||
: Endpoint<UpdateWorkspaceRequest, WorkspaceResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/workspaces/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateWorkspaceRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.Id && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.Name = req.Name;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new WorkspaceResponse(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Plan.ToString(),
|
||||
workspace.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TrackQrApi.Middleware;
|
||||
|
||||
public class GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
var (statusCode, message) = exception switch
|
||||
{
|
||||
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Unauthorized access"),
|
||||
KeyNotFoundException => (HttpStatusCode.NotFound, "Resource not found"),
|
||||
ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
|
||||
InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message),
|
||||
_ => (HttpStatusCode.InternalServerError, "An unexpected error occurred")
|
||||
};
|
||||
|
||||
// Log the exception
|
||||
if (statusCode == HttpStatusCode.InternalServerError)
|
||||
logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
|
||||
else
|
||||
logger.LogWarning("Request error: {StatusCode} - {Message}", (int)statusCode, message);
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
|
||||
var response = new ErrorResponse(
|
||||
(int)statusCode,
|
||||
message,
|
||||
context.TraceIdentifier
|
||||
);
|
||||
|
||||
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, options));
|
||||
}
|
||||
}
|
||||
|
||||
public record ErrorResponse(int StatusCode, string Message, string TraceId);
|
||||
203
src/TrackApi/TrackQrApi/Migrations/20260127192536_InitialCreate.Designer.cs
generated
Normal file
203
src/TrackApi/TrackQrApi/Migrations/20260127192536_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,203 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260127192536_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
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.Project", b =>
|
||||
{
|
||||
b.HasOne("Api.Models.Workspace", "Workspace")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
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.User", b =>
|
||||
{
|
||||
b.Navigation("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.Workspace", b =>
|
||||
{
|
||||
b.Navigation("Domains");
|
||||
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
VerifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Workspaces",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Plan = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Workspaces", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Workspaces_Users_OwnerUserId",
|
||||
column: x => x.OwnerUserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Domains",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Hostname = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
VerificationToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Domains", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Domains_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Projects",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Projects", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Projects_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Domains_Hostname",
|
||||
table: "Domains",
|
||||
column: "Hostname",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Domains_WorkspaceId",
|
||||
table: "Domains",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Projects_WorkspaceId",
|
||||
table: "Projects",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Email",
|
||||
table: "Users",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Workspaces_OwnerUserId",
|
||||
table: "Workspaces",
|
||||
column: "OwnerUserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Domains");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Projects");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Workspaces");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
540
src/TrackApi/TrackQrApi/Migrations/20260127193159_AddShortLinksQRCodesEventsAssets.Designer.cs
generated
Normal file
540
src/TrackApi/TrackQrApi/Migrations/20260127193159_AddShortLinksQRCodesEventsAssets.Designer.cs
generated
Normal file
@@ -0,0 +1,540 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260127193159_AddShortLinksQRCodesEventsAssets")]
|
||||
partial class AddShortLinksQRCodesEventsAssets
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Mime")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DedupeKey")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("IpHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("QRCodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Referrer")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StyleJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShortLinksQRCodesEventsAssets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Assets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
StorageKey = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Mime = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Size = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Assets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Assets_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShortLinks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DomainId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Slug = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
DestinationUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
PasswordHash = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShortLinks", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ShortLinks_Domains_DomainId",
|
||||
column: x => x.DomainId,
|
||||
principalTable: "Domains",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_ShortLinks_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_ShortLinks_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "QRCodeDesigns",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ShortLinkId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
StyleJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
LogoAssetId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_QRCodeDesigns", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_QRCodeDesigns_Assets_LogoAssetId",
|
||||
column: x => x.LogoAssetId,
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_QRCodeDesigns_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_QRCodeDesigns_ShortLinks_ShortLinkId",
|
||||
column: x => x.ShortLinkId,
|
||||
principalTable: "ShortLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_QRCodeDesigns_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Events",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ShortLinkId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
QRCodeId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Type = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
IpHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
UserAgent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
Referrer = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CountryCode = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: true),
|
||||
DeviceType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
DedupeKey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Events", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Events_QRCodeDesigns_QRCodeId",
|
||||
column: x => x.QRCodeId,
|
||||
principalTable: "QRCodeDesigns",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Events_ShortLinks_ShortLinkId",
|
||||
column: x => x.ShortLinkId,
|
||||
principalTable: "ShortLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Events_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_WorkspaceId",
|
||||
table: "Assets",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Events_QRCodeId",
|
||||
table: "Events",
|
||||
column: "QRCodeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Events_ShortLinkId_Timestamp",
|
||||
table: "Events",
|
||||
columns: new[] { "ShortLinkId", "Timestamp" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Events_Timestamp",
|
||||
table: "Events",
|
||||
column: "Timestamp");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Events_WorkspaceId_Timestamp",
|
||||
table: "Events",
|
||||
columns: new[] { "WorkspaceId", "Timestamp" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QRCodeDesigns_LogoAssetId",
|
||||
table: "QRCodeDesigns",
|
||||
column: "LogoAssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QRCodeDesigns_ProjectId",
|
||||
table: "QRCodeDesigns",
|
||||
column: "ProjectId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QRCodeDesigns_ShortLinkId",
|
||||
table: "QRCodeDesigns",
|
||||
column: "ShortLinkId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QRCodeDesigns_WorkspaceId",
|
||||
table: "QRCodeDesigns",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShortLinks_DomainId_Slug",
|
||||
table: "ShortLinks",
|
||||
columns: new[] { "DomainId", "Slug" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShortLinks_ProjectId",
|
||||
table: "ShortLinks",
|
||||
column: "ProjectId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShortLinks_WorkspaceId",
|
||||
table: "ShortLinks",
|
||||
column: "WorkspaceId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Events");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "QRCodeDesigns");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Assets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ShortLinks");
|
||||
}
|
||||
}
|
||||
}
|
||||
540
src/TrackApi/TrackQrApi/Migrations/20260127205418_RefactorAuth.Designer.cs
generated
Normal file
540
src/TrackApi/TrackQrApi/Migrations/20260127205418_RefactorAuth.Designer.cs
generated
Normal file
@@ -0,0 +1,540 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260127205418_refactor auth")]
|
||||
partial class RefactorAuth
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Mime")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DedupeKey")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("IpHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("QRCodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Referrer")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid>("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("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StyleJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LogoAssetId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("ShortLinkId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("QrCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("DomainId", "Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Domains")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.QRCodeDesign", "QRCode")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("QRCodeId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QRCode");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Asset", "LogoAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("LogoAssetId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.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("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Domain", "Domain")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "Owner")
|
||||
.WithMany("Workspaces")
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefactorAuth : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
708
src/TrackApi/TrackQrApi/Migrations/20260130185641_AddQRCodeNameAndLogo.Designer.cs
generated
Normal file
708
src/TrackApi/TrackQrApi/Migrations/20260130185641_AddQRCodeNameAndLogo.Designer.cs
generated
Normal file
@@ -0,0 +1,708 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260130185641_AddQRCodeNameAndLogo")]
|
||||
partial class AddQRCodeNameAndLogo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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("TrackQrApi.Models.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("KeyPrefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Scopes")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeyHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Mime")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("EmailVerificationTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DedupeKey")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("IpHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("QRCodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Referrer")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid>("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("TrackQrApi.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StyleJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LogoAssetId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("ShortLinkId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("QrCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("DomainId", "Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndsAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Domains")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.QRCodeDesign", "QRCode")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("QRCodeId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QRCode");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Asset", "LogoAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("LogoAssetId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.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("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Domain", "Domain")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "Owner")
|
||||
.WithMany("Workspaces")
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQRCodeNameAndLogo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "StripeSubscriptionId",
|
||||
table: "Workspaces",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "SubscriptionEndsAt",
|
||||
table: "Workspaces",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "StripeCustomerId",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "DeletedAt",
|
||||
table: "ShortLinks",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Name",
|
||||
table: "QrCodeDesigns",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
KeyHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
KeyPrefix = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Scopes = table.Column<List<string>>(type: "text[]", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiKeys", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ApiKeys_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmailVerificationTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmailVerificationTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmailVerificationTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PasswordResetTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Used = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PasswordResetTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PasswordResetTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_KeyHash",
|
||||
table: "ApiKeys",
|
||||
column: "KeyHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_WorkspaceId",
|
||||
table: "ApiKeys",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailVerificationTokens_Token",
|
||||
table: "EmailVerificationTokens",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailVerificationTokens_UserId",
|
||||
table: "EmailVerificationTokens",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PasswordResetTokens_Token",
|
||||
table: "PasswordResetTokens",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PasswordResetTokens_UserId",
|
||||
table: "PasswordResetTokens",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKeys");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmailVerificationTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PasswordResetTokens");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StripeSubscriptionId",
|
||||
table: "Workspaces");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubscriptionEndsAt",
|
||||
table: "Workspaces");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StripeCustomerId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeletedAt",
|
||||
table: "ShortLinks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Name",
|
||||
table: "QrCodeDesigns");
|
||||
}
|
||||
}
|
||||
}
|
||||
711
src/TrackApi/TrackQrApi/Migrations/20260130193730_AddProjectDescription.Designer.cs
generated
Normal file
711
src/TrackApi/TrackQrApi/Migrations/20260130193730_AddProjectDescription.Designer.cs
generated
Normal file
@@ -0,0 +1,711 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260130193730_AddProjectDescription")]
|
||||
partial class AddProjectDescription
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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("TrackQrApi.Models.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("KeyPrefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Scopes")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeyHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Mime")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("EmailVerificationTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DedupeKey")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("IpHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("QRCodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Referrer")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid>("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("TrackQrApi.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StyleJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LogoAssetId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("ShortLinkId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("QrCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("DomainId", "Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndsAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Domains")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.QRCodeDesign", "QRCode")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("QRCodeId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QRCode");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Asset", "LogoAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("LogoAssetId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.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("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Domain", "Domain")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "Owner")
|
||||
.WithMany("Workspaces")
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProjectDescription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Description",
|
||||
table: "Projects",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Description",
|
||||
table: "Projects");
|
||||
}
|
||||
}
|
||||
}
|
||||
708
src/TrackApi/TrackQrApi/Migrations/AppDbContextModelSnapshot.cs
Normal file
708
src/TrackApi/TrackQrApi/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,708 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrackQrApi.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("KeyPrefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Scopes")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeyHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Mime")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("EmailVerificationTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DedupeKey")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("IpHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("QRCodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Referrer")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid>("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("TrackQrApi.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StyleJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LogoAssetId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("ShortLinkId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("QrCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("DomainId", "Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndsAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Domains")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Event", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.QRCodeDesign", "QRCode")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("QRCodeId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QRCode");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Asset", "LogoAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("LogoAssetId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.ShortLink", "ShortLink")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.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("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.Domain", "Domain")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Project", "Project")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TrackQrApi.Models.Workspace", "Workspace")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Workspace", b =>
|
||||
{
|
||||
b.HasOne("TrackQrApi.Models.User", "Owner")
|
||||
.WithMany("Workspaces")
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Domain", b =>
|
||||
{
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.Project", b =>
|
||||
{
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.ShortLink", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrackQrApi.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
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/TrackApi/TrackQrApi/Models/ApiKey.cs
Normal file
17
src/TrackApi/TrackQrApi/Models/ApiKey.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace TrackQrApi.Models;
|
||||
|
||||
public class ApiKey
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string KeyHash { get; set; } // Only store hash, never the raw key
|
||||
public required string KeyPrefix { get; set; } // First 8 chars for identification (e.g., "trk_abc1...")
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public List<string>? Scopes { get; set; } // e.g., ["links:read", "links:write", "qrcodes:read"]
|
||||
|
||||
public Workspace Workspace { get; set; } = null!;
|
||||
}
|
||||
20
src/TrackApi/TrackQrApi/Models/Asset.cs
Normal file
20
src/TrackApi/TrackQrApi/Models/Asset.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace TrackQrApi.Models;
|
||||
|
||||
public enum AssetType
|
||||
{
|
||||
Logo
|
||||
}
|
||||
|
||||
public class Asset
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public AssetType Type { get; set; }
|
||||
public required string StorageKey { get; set; }
|
||||
public required string Mime { get; set; }
|
||||
public long Size { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public Workspace Workspace { get; set; } = null!;
|
||||
}
|
||||
22
src/TrackApi/TrackQrApi/Models/Domain.cs
Normal file
22
src/TrackApi/TrackQrApi/Models/Domain.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace TrackQrApi.Models;
|
||||
|
||||
public enum DomainStatus
|
||||
{
|
||||
Pending,
|
||||
Verified,
|
||||
Active
|
||||
}
|
||||
|
||||
public class Domain
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Hostname { get; set; }
|
||||
public DomainStatus Status { get; set; } = DomainStatus.Pending;
|
||||
public required string VerificationToken { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public Workspace Workspace { get; set; } = null!;
|
||||
public ICollection<ShortLink> ShortLinks { get; set; } = [];
|
||||
}
|
||||
13
src/TrackApi/TrackQrApi/Models/EmailVerificationToken.cs
Normal file
13
src/TrackApi/TrackQrApi/Models/EmailVerificationToken.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace TrackQrApi.Models;
|
||||
|
||||
public class EmailVerificationToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public required string Token { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
28
src/TrackApi/TrackQrApi/Models/Event.cs
Normal file
28
src/TrackApi/TrackQrApi/Models/Event.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace TrackQrApi.Models;
|
||||
|
||||
public enum EventType
|
||||
{
|
||||
Click,
|
||||
Scan
|
||||
}
|
||||
|
||||
public class Event
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ShortLinkId { get; set; }
|
||||
public Guid? QRCodeId { get; set; }
|
||||
public EventType Type { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string? IpHash { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public string? Referrer { get; set; }
|
||||
public string? CountryCode { get; set; }
|
||||
public string? DeviceType { get; set; }
|
||||
public string? DedupeKey { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public Workspace Workspace { get; set; } = null!;
|
||||
public ShortLink ShortLink { get; set; } = null!;
|
||||
public QRCodeDesign? QRCode { get; set; }
|
||||
}
|
||||
14
src/TrackApi/TrackQrApi/Models/PasswordResetToken.cs
Normal file
14
src/TrackApi/TrackQrApi/Models/PasswordResetToken.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace TrackQrApi.Models;
|
||||
|
||||
public class PasswordResetToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public required string Token { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public bool Used { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
15
src/TrackApi/TrackQrApi/Models/Project.cs
Normal file
15
src/TrackApi/TrackQrApi/Models/Project.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace TrackQrApi.Models;
|
||||
|
||||
public class Project
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public Workspace Workspace { get; set; } = null!;
|
||||
public ICollection<ShortLink> ShortLinks { get; set; } = [];
|
||||
public ICollection<QRCodeDesign> QRCodeDesigns { get; set; } = [];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user