chore: correct namespaces and hiearchy

This commit is contained in:
2026-01-31 02:16:32 -05:00
parent 56d393e127
commit 19e2c22111
136 changed files with 1366 additions and 1404 deletions

482
src/TrackApi/TrackQrApi/.gitignore vendored Normal file
View 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

View 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);
});
}
}

View File

@@ -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
);

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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"
};
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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
);

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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
);

View File

@@ -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}";
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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}
""");
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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;"">&copy; 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;"">&copy; 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;"">&copy; 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);
}
}

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View 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; }
}

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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);
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View 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!;
}

View 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!;
}

View 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; } = [];
}

View 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!;
}

View 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; }
}

View 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!;
}

View 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