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

13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/projectSettingsUpdater.xml
/modules.xml
/contentModel.xml
/.idea.TrackQrApi.iml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1 @@
TrackQrApi

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="trakqr@localhost" uuid="d4e0d8dc-9924-4b70-a2e0-ee27030702dd">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<configured-by-url>true</configured-by-url>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5400/trakqr?password=P%40ssword123%21&amp;user=sa</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>

View File

@@ -1,36 +1,31 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using TrackQrApi.Features.Analytics.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class AnalyticsEndpointTests : IClassFixture<ApiWebApplicationFactory>
public class AnalyticsEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly HttpClient _noRedirectClient;
private readonly HttpClient _client = factory.CreateClient();
public AnalyticsEndpointTests(ApiWebApplicationFactory factory)
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
_client = factory.CreateClient();
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
AllowAutoRedirect = false
});
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -217,4 +212,4 @@ public class AnalyticsEndpointTests : IClassFixture<ApiWebApplicationFactory>
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,20 +1,37 @@
using api.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
.Build();
private bool _containerStarted = false;
private bool _containerStarted;
public async Task InitializeAsync()
{
// Ensure container is started (might already be started from ConfigureWebHost)
EnsureContainerStarted();
// Run migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
private void EnsureContainerStarted()
{
@@ -47,40 +64,18 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
builder.ConfigureTestServices(services =>
{
// Remove existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
if (descriptor != null) services.Remove(descriptor);
// Add DbContext with Testcontainers connection string
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
// Ensure container is started (might already be started from ConfigureWebHost)
EnsureContainerStarted();
// Run migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
/// <summary>
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
/// </summary>
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
{
@@ -89,8 +84,8 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
var workspace = await db.Workspaces.FindAsync(workspaceId);
if (workspace != null)
{
workspace.Plan = api.Models.WorkspacePlan.Pro;
workspace.Plan = WorkspacePlan.Pro;
await db.SaveChangesAsync();
}
}
}
}

View File

@@ -1,14 +1,15 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Assets.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using TrackQrApi.Features.Assets.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class AssetEndpointTests(ApiWebApplicationFactory factory)
public class AssetEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
@@ -17,9 +18,7 @@ public class AssetEndpointTests(ApiWebApplicationFactory factory)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
@@ -243,4 +242,4 @@ public class AssetEndpointTests(ApiWebApplicationFactory factory)
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.CacheControl!.MaxAge.Should().Be(TimeSpan.FromSeconds(31536000));
}
}
}

View File

@@ -1,11 +1,12 @@
using System.Net;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class AuthControllerTests(ApiWebApplicationFactory factory)
public class AuthControllerTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
@@ -222,4 +223,4 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result!.User.Email.Should().Be("casetest@example.com");
}
}
}

View File

@@ -1,25 +1,25 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Domains.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class DomainEndpointTests(ApiWebApplicationFactory factory)
public class DomainEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = true)
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
bool upgradeToPro = true)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
@@ -29,10 +29,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var workspaceId = workspaces!.Workspaces.First().Id;
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
return (token, workspaceId);
}
@@ -45,7 +42,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "example.com" });
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "example.com" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -81,7 +79,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
@@ -113,7 +112,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-domain@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "get-test.com" });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "get-test.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
@@ -147,7 +147,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-domain@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "to-delete.com" });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "to-delete.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
@@ -168,11 +169,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("verify-domain@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "unverified.com" });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "unverified.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -189,11 +192,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// The verification mock accepts domains starting with "verified-"
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "verified-test.com" });
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains",
new { Hostname = "verified-test.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -210,7 +215,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var (token2, _) = await GetAuthAndWorkspaceAsync("domain-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/domains", new { Hostname = "user1-domain.com" });
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/domains",
new { Hostname = "user1-domain.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act - Try to access as user2
@@ -222,4 +228,4 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,35 +1,30 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
public class EventTrackingTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly HttpClient _noRedirectClient;
private readonly HttpClient _client = factory.CreateClient();
public EventTrackingTests(ApiWebApplicationFactory factory)
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
_client = factory.CreateClient();
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
AllowAutoRedirect = false
});
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -82,10 +77,7 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
// Act - Click the same link multiple times rapidly
var responses = new List<HttpResponseMessage>();
for (int i = 0; i < 5; i++)
{
responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
}
for (var i = 0; i < 5; i++) responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
// Assert - All should redirect successfully (deduplication happens silently)
responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Redirect);
@@ -114,7 +106,8 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ua-link");
// Set a custom user agent
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
// Act
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
@@ -139,4 +132,4 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}
}

View File

@@ -1,15 +1,16 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Projects.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Projects.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class LinkEndpointTests(ApiWebApplicationFactory factory)
public class LinkEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
@@ -18,9 +19,7 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -127,7 +126,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
var projectResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
@@ -150,8 +150,10 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example1.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example2.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://example1.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://example2.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links");
@@ -169,11 +171,14 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
var projectResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://no-project.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://no-project.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
@@ -372,7 +377,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
// Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{created!.Id}");
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
var updateResponse =
await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/links/{created.Id}");
// Assert - All should return NotFound (not exposing existence)
@@ -380,4 +386,4 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,14 +1,15 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Projects.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class ProjectEndpointTests(ApiWebApplicationFactory factory)
public class ProjectEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
@@ -17,9 +18,7 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -71,7 +70,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
@@ -102,7 +102,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
@@ -136,11 +137,13 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}", new { Name = "Updated" });
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}",
new { Name = "Updated" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -155,7 +158,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
@@ -178,7 +182,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
// Create project as user1
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Try to access as user2
@@ -186,7 +191,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
// Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/projects/{created!.Id}");
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}", new { Name = "Hacked" });
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}",
new { Name = "Hacked" });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/projects/{created.Id}");
// Assert - All should return NotFound (not exposing existence)
@@ -194,4 +200,4 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,15 +1,16 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.QRCodes.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.QRCodes.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
public class QrCodeEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
@@ -18,9 +19,7 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -139,7 +138,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-get-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
@@ -173,7 +173,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-update-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
@@ -201,7 +202,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-delete-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
@@ -223,7 +225,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-preview-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
@@ -244,7 +247,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-export-png-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
@@ -263,7 +267,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-export-svg-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
@@ -280,7 +285,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
// Arrange - Create two users
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("qr-user1@example.com");
var link = await CreateLinkAsync(workspaceId1, "qr-user1-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
var (token2, _) = await SetupAuthAndWorkspaceAsync("qr-user2@example.com");
@@ -296,4 +302,4 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -1,35 +1,32 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
public class RedirectEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly HttpClient _noRedirectClient;
private readonly HttpClient _client = factory.CreateClient();
public RedirectEndpointTests(ApiWebApplicationFactory factory)
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
_client = factory.CreateClient();
// Create a client that doesn't follow redirects
_noRedirectClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
AllowAutoRedirect = false
});
// Create a client that doesn't follow redirects
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
@@ -42,7 +39,8 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null, string? password = null)
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null,
string? password = null)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
@@ -129,7 +127,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-password@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", password: "secret123");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", "secret123");
// Act
var response = await _client.GetAsync($"/{link.Slug}");
@@ -144,7 +142,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-ok@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", password: "secret123");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", "secret123");
// Act
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
@@ -159,7 +157,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-wrong@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", password: "secret123");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", "secret123");
// Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
@@ -173,7 +171,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-empty@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", password: "secret123");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", "secret123");
// Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
@@ -212,4 +210,4 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TrackQrApi\TrackQrApi.csproj"/>
</ItemGroup>
</Project>

View File

@@ -1,24 +1,25 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace Api.Tests;
namespace TrackQrApi.Tests;
public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
public class WorkspaceEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = false)
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
bool upgradeToPro = false)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
@@ -27,10 +28,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
return (token, workspaceId);
}
@@ -72,7 +70,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
{
// Arrange - upgrade to Pro to allow creating additional workspaces
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", upgradeToPro: true);
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
@@ -152,7 +150,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
{
// Arrange - upgrade to Pro to allow creating additional workspaces
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", upgradeToPro: true);
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });
@@ -194,4 +192,4 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="TrackQrApi.Tests\TrackQrApi.Tests.csproj"/>
<Project Path="TrackQrApi\TrackQrApi.csproj"/>
</Solution>

View File

@@ -1,9 +1,9 @@
using api.Models;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Models;
namespace api.Data;
namespace TrackQrApi.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options)
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public DbSet<User> Users => Set<User>();
@@ -225,4 +225,4 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Analytics.Common;
namespace TrackQrApi.Features.Analytics.Common;
public record AnalyticsSummary(
int TotalClicks,
@@ -37,4 +37,4 @@ public record LinkAnalyticsResponse(
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
);
);

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Analytics.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Models;
namespace api.Features.Analytics.Endpoints;
namespace TrackQrApi.Features.Analytics.Endpoints;
public class LinkAnalyticsRequest
{
@@ -59,26 +59,20 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
var eventsQuery = db.Events
.Where(e => e.ShortLinkId == req.Id);
if (startDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.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(
TotalClicks: events.Count(e => e.Type == EventType.Click),
TotalScans: events.Count(e => e.Type == EventType.Scan),
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
FirstEvent: events.MinBy(e => e.Timestamp)?.Timestamp,
LastEvent: events.MaxBy(e => e.Timestamp)?.Timestamp
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
@@ -86,9 +80,9 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
Date: g.Key,
Clicks: g.Count(e => e.Type == EventType.Click),
Scans: g.Count(e => e.Type == EventType.Scan)
g.Key,
g.Count(e => e.Type == EventType.Click),
g.Count(e => e.Type == EventType.Scan)
))
.ToList();
@@ -131,16 +125,16 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
.ToList();
var response = new LinkAnalyticsResponse(
LinkId: link.Id,
Slug: link.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
link.Id,
link.Slug,
summary,
timeSeries,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
@@ -166,4 +160,4 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
return url.Length > 50 ? url[..50] : url;
}
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Analytics.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Models;
namespace api.Features.Analytics.Endpoints;
namespace TrackQrApi.Features.Analytics.Endpoints;
public class WorkspaceAnalyticsRequest
{
@@ -56,26 +56,20 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
var eventsQuery = db.Events
.Where(e => e.WorkspaceId == req.WorkspaceId);
if (startDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.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(
TotalClicks: events.Count(e => e.Type == EventType.Click),
TotalScans: events.Count(e => e.Type == EventType.Scan),
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
FirstEvent: events.Count > 0 ? events.Min(e => e.Timestamp) : null,
LastEvent: events.Count > 0 ? events.Max(e => e.Timestamp) : null
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
@@ -83,9 +77,9 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
Date: g.Key,
Clicks: g.Count(e => e.Type == EventType.Click),
Scans: g.Count(e => e.Type == EventType.Scan)
g.Key,
g.Count(e => e.Type == EventType.Click),
g.Count(e => e.Type == EventType.Scan)
))
.ToList();
@@ -146,15 +140,15 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
.ToList();
var response = new WorkspaceAnalyticsResponse(
Summary: summary,
TimeSeries: timeSeries,
TopLinks: topLinks,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
summary,
timeSeries,
topLinks,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
@@ -180,4 +174,4 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
return url.Length > 50 ? url[..50] : url;
}
}
}
}

View File

@@ -1,12 +1,13 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using System.Text;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Models;
namespace api.Features.ApiKeys.Endpoints;
namespace TrackQrApi.Features.ApiKeys.Endpoints;
public class CreateApiKeyRequest
{
@@ -32,7 +33,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/api-keys");
Post("/workspaces/{WorkspaceId}/TrackQrApi-keys");
}
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
@@ -53,7 +54,8 @@ public class CreateApiKeyEndpoint(AppDbContext db)
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);
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400,
cancellation: ct);
return;
}
@@ -73,7 +75,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
Scopes = req.Scopes,
ExpiresAt = req.ExpiresAt,
CreatedAt = DateTime.UtcNow,
IsActive = true,
IsActive = true
};
db.ApiKeys.Add(apiKey);
@@ -87,7 +89,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
KeyPrefix = keyPrefix,
Scopes = apiKey.Scopes,
ExpiresAt = apiKey.ExpiresAt,
CreatedAt = apiKey.CreatedAt,
CreatedAt = apiKey.CreatedAt
};
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
@@ -95,8 +97,8 @@ public class CreateApiKeyEndpoint(AppDbContext db)
private static string ComputeSha256Hash(string input)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLower();
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.ApiKeys.Endpoints;
namespace TrackQrApi.Features.ApiKeys.Endpoints;
public class DeleteApiKeyRequest
{
@@ -17,7 +17,7 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
{
public override void Configure()
{
Delete("/workspaces/{WorkspaceId}/api-keys/{Id}");
Delete("/workspaces/{WorkspaceId}/TrackQrApi-keys/{Id}");
}
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
@@ -46,6 +46,6 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
db.ApiKeys.Remove(apiKey);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.ApiKeys.Endpoints;
namespace TrackQrApi.Features.ApiKeys.Endpoints;
public class ListApiKeysRequest
{
@@ -33,7 +33,7 @@ public class ListApiKeysEndpoint(AppDbContext db)
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/api-keys");
Get("/workspaces/{WorkspaceId}/TrackQrApi-keys");
}
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
@@ -62,11 +62,11 @@ public class ListApiKeysEndpoint(AppDbContext db)
ExpiresAt = k.ExpiresAt,
LastUsedAt = k.LastUsedAt,
CreatedAt = k.CreatedAt,
IsActive = k.IsActive,
IsActive = k.IsActive
})
.ToListAsync(ct);
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Assets.Common;
namespace TrackQrApi.Features.Assets.Common;
public record AssetResponse(
Guid Id,
@@ -12,4 +12,4 @@ public record AssetResponse(
public record AssetListResponse(
IEnumerable<AssetResponse> Assets
);
);

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Assets.Services;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Assets.Endpoints;
namespace TrackQrApi.Features.Assets.Endpoints;
public class DeleteAssetRequest
{
@@ -27,7 +27,8 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
var asset = await db.Assets
.Include(a => a.Workspace)
.FirstOrDefaultAsync(a => a.Id == req.Id && a.WorkspaceId == req.WorkspaceId && a.Workspace.OwnerUserId == userId, ct);
.FirstOrDefaultAsync(
a => a.Id == req.Id && a.WorkspaceId == req.WorkspaceId && a.Workspace.OwnerUserId == userId, ct);
if (asset is null)
{
@@ -55,6 +56,6 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
db.Assets.Remove(asset);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Asset deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Asset deleted"), cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Assets.Services;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Assets.Endpoints;
namespace TrackQrApi.Features.Assets.Endpoints;
public class GetAssetRequest
{
@@ -50,4 +50,4 @@ public class GetAssetEndpoint(AppDbContext db, IAssetStorageService storage)
await stream.CopyToAsync(HttpContext.Response.Body, ct);
await stream.DisposeAsync();
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Assets.Common;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Assets.Common;
using TrackQrApi.Features.Assets.Services;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Assets.Endpoints;
namespace TrackQrApi.Features.Assets.Endpoints;
public class ListAssetsRequest
{
@@ -52,6 +52,6 @@ public class ListAssetsEndpoint(AppDbContext db, IAssetStorageService storage)
))
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Assets.Common;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using api.Models;
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 api.Features.Assets.Endpoints;
namespace TrackQrApi.Features.Assets.Endpoints;
public class UploadAssetRequest
{
@@ -39,9 +39,8 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
}
// Get file from form
IFormFile? file = req.File;
var file = req.File;
if (file is null)
{
try
{
file = HttpContext.Request.Form.Files.FirstOrDefault();
@@ -50,7 +49,6 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
{
// Form access failed - no file uploaded
}
}
if (file is null || file.Length == 0)
{
@@ -111,4 +109,4 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Assets.Services;
namespace TrackQrApi.Features.Assets.Services;
public interface IAssetStorageService
{
@@ -19,10 +19,7 @@ public class LocalAssetStorageService : IAssetStorageService
_basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
// Ensure directory exists
if (!Directory.Exists(_basePath))
{
Directory.CreateDirectory(_basePath);
}
if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath);
}
public async Task<string> StoreAsync(Stream stream, string filename, string contentType)
@@ -44,10 +41,7 @@ public class LocalAssetStorageService : IAssetStorageService
{
var filePath = Path.Combine(_basePath, storageKey);
if (!File.Exists(filePath))
{
return Task.FromResult<(Stream, string)?>(null);
}
if (!File.Exists(filePath)) return Task.FromResult<(Stream, string)?>(null);
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
var contentType = GetContentType(storageKey);
@@ -87,4 +81,4 @@ public class LocalAssetStorageService : IAssetStorageService
_ => "application/octet-stream"
};
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Auth.Common;
namespace TrackQrApi.Features.Auth.Common;
public record AuthResponse(
string Token,
@@ -12,4 +12,4 @@ public record UserInfo(
bool IsVerified
);
public record MessageResponse(string Message);
public record MessageResponse(string Message);

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class ChangePasswordRequest
{
@@ -46,7 +46,8 @@ public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRe
// 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);
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400,
cancellation: ct);
return;
}
@@ -56,4 +57,4 @@ public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRe
await HttpContext.Response.SendAsync(new MessageResponse("Password changed successfully"), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class DeleteAccountRequest
{
@@ -56,4 +56,4 @@ public class DeleteAccountEndpoint(AppDbContext db) : Endpoint<DeleteAccountRequ
await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct);
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Email.Services;
using TrackQrApi.Models;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class ForgotPasswordRequest
{
@@ -46,10 +46,7 @@ public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
.Where(t => t.UserId == user.Id && !t.Used)
.ToListAsync(ct);
foreach (var token in existingTokens)
{
token.Used = true;
}
foreach (var token in existingTokens) token.Used = true;
// Generate new token
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
@@ -87,7 +84,6 @@ public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
// Always return success to prevent email enumeration
await HttpContext.Response.SendAsync(
new MessageResponse("If the email exists, a reset link will be sent"),
200,
cancellation: ct);
}
}
}

View File

@@ -1,9 +1,9 @@
using System.Security.Claims;
using api.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public record ProfileResponse(
Guid Id,
@@ -41,4 +41,4 @@ public class GetProfileEndpoint(AppDbContext db) : EndpointWithoutRequest<Profil
await HttpContext.Response.SendAsync(user, cancellation: ct);
}
}
}

View File

@@ -1,16 +1,16 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Auth.Settings;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Auth.Settings;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class LoginRequest
{
@@ -31,7 +31,7 @@ public class LoginValidator : Validator<LoginRequest>
}
}
public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
: Endpoint<LoginRequest, AuthResponse>
{
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
@@ -50,7 +50,8 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
{
await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401,
cancellation: ct);
return;
}
@@ -68,19 +69,19 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
_jwtSettings.Issuer,
_jwtSettings.Audience,
claims,
expires: expiresAt,
signingCredentials: credentials
);
var response = new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
new JwtSecurityTokenHandler().WriteToken(token),
expiresAt,
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -2,18 +2,18 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Auth.Settings;
using api.Features.Email.Services;
using api.Models;
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 api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class RegisterRequest
{
@@ -55,7 +55,8 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
if (await db.Users.AnyAsync(u => u.Email == normalizedEmail, ct))
{
await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409,
cancellation: ct);
return;
}
@@ -122,17 +123,17 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
_jwtSettings.Issuer,
_jwtSettings.Audience,
claims,
expires: expiresAt,
signingCredentials: credentials
);
return new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
new JwtSecurityTokenHandler().WriteToken(token),
expiresAt,
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
);
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Email.Services;
using TrackQrApi.Models;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
{
@@ -29,7 +29,8 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
if (user.VerifiedAt != null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400,
cancellation: ct);
return;
}
@@ -60,4 +61,4 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class ResetPasswordRequest
{
@@ -82,7 +82,6 @@ public class ResetPasswordEndpoint(AppDbContext db)
await HttpContext.Response.SendAsync(
new MessageResponse("Password has been reset successfully"),
200,
cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class UpdateProfileRequest
{
@@ -46,7 +46,8 @@ public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequ
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);
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409,
cancellation: ct);
return;
}
@@ -65,4 +66,4 @@ public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequ
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints;
namespace TrackQrApi.Features.Auth.Endpoints;
public class VerifyEmailRequest
{
@@ -35,7 +35,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
if (token == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400,
cancellation: ct);
return;
}
@@ -43,7 +44,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
{
db.EmailVerificationTokens.Remove(token);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400,
cancellation: ct);
return;
}
@@ -56,4 +58,4 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Auth.Settings;
namespace TrackQrApi.Features.Auth.Settings;
public class JwtSettings
{
@@ -6,4 +6,4 @@ public class JwtSettings
public required string Issuer { get; set; }
public required string Audience { get; set; }
public int ExpirationMinutes { get; set; } = 60;
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Billing.Common;
namespace TrackQrApi.Features.Billing.Common;
public record CheckoutSessionRequest(
Guid WorkspaceId,
@@ -20,4 +20,4 @@ public record SubscriptionResponse(
DateTime? CurrentPeriodEnd,
bool IsActive,
bool CancelAtPeriodEnd
);
);

View File

@@ -1,14 +1,14 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using api.Models;
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 api.Features.Billing.Endpoints;
namespace TrackQrApi.Features.Billing.Endpoints;
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
{
@@ -56,7 +56,8 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Workspace already has an active subscription. Use the billing portal to manage it."),
new MessageResponse(
"Workspace already has an active subscription. Use the billing portal to manage it."),
400,
cancellation: ct);
return;
@@ -84,4 +85,4 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
cancellation: ct);
}
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using FastEndpoints;
using FluentValidation;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Billing.Common;
using TrackQrApi.Features.Billing.Services;
namespace api.Features.Billing.Endpoints;
namespace TrackQrApi.Features.Billing.Endpoints;
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
{
@@ -57,4 +57,4 @@ public class CreatePortalSessionEndpoint(IStripeService stripeService)
cancellation: ct);
}
}
}
}

View File

@@ -1,12 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
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 api.Features.Billing.Endpoints;
namespace TrackQrApi.Features.Billing.Endpoints;
public class GetSubscriptionRequest
{
@@ -34,7 +35,7 @@ public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeServi
return;
}
var isActive = workspace.Plan != Models.WorkspacePlan.Free;
var isActive = workspace.Plan != WorkspacePlan.Free;
var cancelAtPeriodEnd = false;
// Get live subscription status from Stripe if exists
@@ -59,4 +60,4 @@ public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeServi
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,11 @@
using api.Features.Billing.Services;
using api.Features.Billing.Settings;
using FastEndpoints;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
using TrackQrApi.Features.Billing.Services;
using TrackQrApi.Features.Billing.Settings;
namespace api.Features.Billing.Endpoints;
namespace TrackQrApi.Features.Billing.Endpoints;
public class StripeWebhookEndpoint(
IStripeService stripeService,
@@ -38,27 +39,20 @@ public class StripeWebhookEndpoint(
switch (stripeEvent.Type)
{
case "checkout.session.completed":
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
if (session != null)
{
await stripeService.HandleCheckoutCompletedAsync(session, ct);
}
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":
@@ -76,7 +70,8 @@ public class StripeWebhookEndpoint(
catch (StripeException ex)
{
logger.LogError(ex, "Stripe webhook signature verification failed");
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400, cancellation: ct);
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400,
cancellation: ct);
}
catch (Exception ex)
{
@@ -84,4 +79,4 @@ public class StripeWebhookEndpoint(
await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct);
}
}
}
}

View File

@@ -1,16 +1,18 @@
using api.Data;
using api.Features.Billing.Settings;
using api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
using TrackQrApi.Data;
using TrackQrApi.Features.Billing.Settings;
using TrackQrApi.Models;
namespace api.Features.Billing.Services;
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> 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);
@@ -23,9 +25,9 @@ public interface IStripeService
public class StripeService : IStripeService
{
private readonly ILogger<StripeService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly StripeSettings _settings;
private readonly ILogger<StripeService> _logger;
public StripeService(
IServiceScopeFactory scopeFactory,
@@ -51,7 +53,7 @@ public class StripeService : IStripeService
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct)
?? throw new InvalidOperationException("User not found");
?? throw new InvalidOperationException("User not found");
// Get or create Stripe customer
var customerId = user.StripeCustomerId;
@@ -73,10 +75,7 @@ public class StripeService : IStripeService
}
var priceId = GetPriceIdForPlan(plan);
if (string.IsNullOrEmpty(priceId))
{
throw new InvalidOperationException($"No price configured for plan: {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
@@ -122,12 +121,10 @@ public class StripeService : IStripeService
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct)
?? throw new InvalidOperationException("User not found");
?? 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
@@ -202,10 +199,7 @@ public class StripeService : IStripeService
if (!string.IsNullOrEmpty(session.SubscriptionId))
{
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
if (subscription != null)
{
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
}
if (subscription != null) workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
}
await db.SaveChangesAsync(ct);
@@ -247,11 +241,9 @@ public class StripeService : IStripeService
// 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);
}
@@ -299,4 +291,4 @@ public class StripeService : IStripeService
return WorkspacePlan.Business;
return WorkspacePlan.Free;
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Billing.Settings;
namespace TrackQrApi.Features.Billing.Settings;
public class StripeSettings
{
@@ -6,4 +6,4 @@ public class StripeSettings
public string WebhookSecret { get; set; } = string.Empty;
public string ProPriceId { get; set; } = string.Empty;
public string BusinessPriceId { get; set; } = string.Empty;
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Domains.Common;
namespace TrackQrApi.Features.Domains.Common;
public record DomainResponse(
Guid Id,
@@ -20,4 +20,4 @@ public record DomainVerificationResponse(
bool IsVerified,
string Status,
string? Message
);
);

View File

@@ -1,15 +1,15 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using api.Features.Plans.Services;
using api.Models;
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 api.Features.Domains.Endpoints;
namespace TrackQrApi.Features.Domains.Endpoints;
public class AddDomainRequest
{
@@ -70,7 +70,8 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
if (domainExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Domain is already registered"), 409, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Domain is already registered"), 409,
cancellation: ct);
return;
}
@@ -113,4 +114,4 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
{
return $"TXT _trakqr-verification {token}";
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Domains.Endpoints;
namespace TrackQrApi.Features.Domains.Endpoints;
public class DeleteDomainRequest
{
@@ -26,7 +26,8 @@ public class DeleteDomainEndpoint(AppDbContext db)
var domain = await db.Domains
.Include(d => d.Workspace)
.FirstOrDefaultAsync(d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
.FirstOrDefaultAsync(
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
if (domain is null)
{
@@ -50,6 +51,6 @@ public class DeleteDomainEndpoint(AppDbContext db)
db.Domains.Remove(domain);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Domain deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Domain deleted"), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Domains.Common;
namespace api.Features.Domains.Endpoints;
namespace TrackQrApi.Features.Domains.Endpoints;
public class GetDomainRequest
{
@@ -45,6 +45,6 @@ public class GetDomainEndpoint(AppDbContext db)
domain.CreatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Domains.Common;
namespace api.Features.Domains.Endpoints;
namespace TrackQrApi.Features.Domains.Endpoints;
public class ListDomainsRequest
{
@@ -48,6 +48,6 @@ public class ListDomainsEndpoint(AppDbContext db)
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new DomainListResponse(domains), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new DomainListResponse(domains), cancellation: ct);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Domains.Common;
using TrackQrApi.Models;
namespace api.Features.Domains.Endpoints;
namespace TrackQrApi.Features.Domains.Endpoints;
public class VerifyDomainRequest
{
@@ -28,7 +28,8 @@ public class VerifyDomainEndpoint(AppDbContext db)
var domain = await db.Domains
.Include(d => d.Workspace)
.FirstOrDefaultAsync(d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
.FirstOrDefaultAsync(
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
if (domain is null)
{
@@ -46,7 +47,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(),
"Domain is already verified"
);
await HttpContext.Response.SendAsync(alreadyResponse, 200, cancellation: ct);
await HttpContext.Response.SendAsync(alreadyResponse, cancellation: ct);
return;
}
@@ -65,7 +66,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(),
"Domain verified successfully"
);
await HttpContext.Response.SendAsync(successResponse, 200, cancellation: ct);
await HttpContext.Response.SendAsync(successResponse, cancellation: ct);
}
else
{
@@ -76,7 +77,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(),
$"Verification failed. Please add a TXT record for _trakqr-verification.{domain.Hostname} with value: {domain.VerificationToken}"
);
await HttpContext.Response.SendAsync(failedResponse, 200, cancellation: ct);
await HttpContext.Response.SendAsync(failedResponse, cancellation: ct);
}
}
@@ -88,4 +89,4 @@ public class VerifyDomainEndpoint(AppDbContext db)
var isVerified = hostname.StartsWith("verified-");
return Task.FromResult(isVerified);
}
}
}

View File

@@ -1,22 +1,18 @@
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
using TrackQrApi.Features.Email.Templates;
namespace api.Features.Email.Services;
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.
/// Development email service that logs emails to console instead of sending them.
/// Useful for testing without a real SMTP server.
/// </summary>
public class ConsoleEmailService : IEmailService
public class ConsoleEmailService(
IOptions<EmailSettings> settings,
ILogger<ConsoleEmailService> logger)
: IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<ConsoleEmailService> _logger;
public ConsoleEmailService(IOptions<EmailSettings> settings, ILogger<ConsoleEmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
private readonly EmailSettings _settings = settings.Value;
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
{
@@ -47,14 +43,15 @@ public class ConsoleEmailService : IEmailService
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
{
_logger.LogInformation($"""
logger.LogInformation($"""
EMAIL (Console Mode)
To: {toEmail}
Subject: {subject}
Action URL: {actionUrl}
""");
EMAIL (Console Mode)
To: {toEmail}
Subject: {subject}
Action URL: {actionUrl}
""");
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Email.Services;
namespace TrackQrApi.Features.Email.Services;
public interface IEmailService
{
@@ -33,4 +33,4 @@ public class SmtpSettings
public class SendGridSettings
{
public string ApiKey { get; set; } = string.Empty;
}
}

View File

@@ -1,14 +1,14 @@
using System.Net;
using System.Net.Mail;
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
using TrackQrApi.Features.Email.Templates;
namespace api.Features.Email.Services;
namespace TrackQrApi.Features.Email.Services;
public class SmtpEmailService : IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<SmtpEmailService> _logger;
private readonly EmailSettings _settings;
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
{
@@ -25,7 +25,8 @@ public class SmtpEmailService : IEmailService
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
}
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
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);
@@ -43,7 +44,8 @@ public class SmtpEmailService : IEmailService
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
}
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody, CancellationToken ct)
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody,
CancellationToken ct)
{
if (_settings.Smtp == null)
{
@@ -76,9 +78,7 @@ public class SmtpEmailService : IEmailService
};
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);
@@ -89,4 +89,4 @@ public class SmtpEmailService : IEmailService
throw;
}
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Email.Templates;
namespace TrackQrApi.Features.Email.Templates;
public static class EmailTemplates
{
@@ -218,4 +218,4 @@ Go to your dashboard: {dashboardUrl}
return (subject, htmlBody, textBody);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace api.Features.Events.Services;
namespace TrackQrApi.Features.Events.Services;
public interface IEventTrackingService
{
@@ -13,7 +13,10 @@ public interface IEventTrackingService
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
}
public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpService geoIpService, ILogger<EventTrackingService> logger)
public class EventTrackingService(
IServiceScopeFactory scopeFactory,
IGeoIpService geoIpService,
ILogger<EventTrackingService> logger)
: IEventTrackingService
{
// Dedupe window - same visitor clicking same link within this window counts as one
@@ -112,10 +115,8 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
// 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";
}
@@ -164,4 +165,4 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
return value.Length <= maxLength ? value : value[..maxLength];
}
}
}

View File

@@ -1,7 +1,7 @@
using System.Net;
using MaxMind.GeoIP2;
namespace api.Features.Events.Services;
namespace TrackQrApi.Features.Events.Services;
public interface IGeoIpService
{
@@ -10,8 +10,8 @@ public interface IGeoIpService
public class GeoIpService : IGeoIpService, IDisposable
{
private readonly DatabaseReader? _reader;
private readonly ILogger<GeoIpService> _logger;
private readonly DatabaseReader? _reader;
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
{
@@ -19,7 +19,6 @@ public class GeoIpService : IGeoIpService, IDisposable
var dbPath = configuration["GeoIP:DatabasePath"];
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
{
try
{
_reader = new DatabaseReader(dbPath);
@@ -29,11 +28,13 @@ public class GeoIpService : IGeoIpService, IDisposable
{
_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)
@@ -44,20 +45,11 @@ public class GeoIpService : IGeoIpService, IDisposable
try
{
// Handle localhost and private IPs
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress))
{
return null;
}
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) return null;
if (!IPAddress.TryParse(ipAddress, out var ip))
{
return null;
}
if (!IPAddress.TryParse(ipAddress, out var ip)) return null;
if (_reader.TryCountry(ip, out var response))
{
return response?.Country?.IsoCode;
}
if (_reader.TryCountry(ip, out var response)) return response?.Country?.IsoCode;
}
catch (Exception ex)
{
@@ -87,9 +79,4 @@ public class GeoIpService : IGeoIpService, IDisposable
return false;
}
public void Dispose()
{
_reader?.Dispose();
}
}
}

View File

@@ -1,12 +1,12 @@
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Common;
public class LinkDto
{
public required Guid Id { get; set; }
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

@@ -1,4 +1,4 @@
namespace api.Features.Links.Common;
namespace TrackQrApi.Features.Links.Common;
public record LinkResponse(
Guid Id,
@@ -18,4 +18,4 @@ public record LinkResponse(
public record LinkListResponse(
IEnumerable<LinkResponse> Links
);
);

View File

@@ -1,6 +1,6 @@
using System.Security.Cryptography;
namespace api.Features.Links.Common;
namespace TrackQrApi.Features.Links.Common;
public static class SlugGenerator
{
@@ -11,4 +11,4 @@ public static class SlugGenerator
{
return RandomNumberGenerator.GetString(Chars, length);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Models;
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class BulkCreateLinksRequest
{
@@ -59,7 +59,8 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
// 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);
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400,
cancellation: ct);
return;
}
@@ -70,7 +71,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
var linkLimit = GetPlanLinkLimit(workspace.Plan);
for (int i = 0; i < req.Links.Count; i++)
for (var i = 0; i < req.Links.Count; i++)
{
var item = req.Links[i];
@@ -130,7 +131,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
Title = item.Title,
Status = ShortLinkStatus.Active,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
db.ShortLinks.Add(link);
@@ -143,7 +144,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
Title = link?.Title,
Status = link.Status.ToString(),
ClickCount = 0,
CreatedAt = link.CreatedAt,
CreatedAt = link.CreatedAt
});
}
@@ -174,4 +175,4 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
_ => 100 // Free plan
};
}
}
}

View File

@@ -1,14 +1,14 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Plans.Services;
using api.Models;
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 api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class CreateLinkRequest
{
@@ -30,7 +30,8 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
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")
.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)
@@ -107,7 +108,8 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
if (slugExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409,
cancellation: ct);
return;
}
}
@@ -147,4 +149,4 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class DeleteLinkRequest
{
@@ -42,6 +42,6 @@ public class DeleteLinkEndpoint(AppDbContext db)
link.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class GetLinkRequest
{
@@ -26,7 +26,8 @@ public class GetLinkEndpoint(AppDbContext db)
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)
.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,
@@ -50,6 +51,6 @@ public class GetLinkEndpoint(AppDbContext db)
return;
}
await HttpContext.Response.SendAsync(link, 200, cancellation: ct);
await HttpContext.Response.SendAsync(link, cancellation: ct);
}
}
}

View File

@@ -1,11 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Models;
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class ListLinksRequest
{
@@ -41,22 +42,14 @@ public class ListLinksEndpoint(AppDbContext db)
.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);
}
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);
}
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<Models.ShortLinkStatus>(req.Status, true, out var status))
{
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)
@@ -77,6 +70,6 @@ public class ListLinksEndpoint(AppDbContext db)
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new LinkListResponse(links), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new LinkListResponse(links), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class RestoreLinkRequest
{
@@ -60,6 +60,6 @@ public class RestoreLinkEndpoint(AppDbContext db)
link.DeletedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Models;
namespace api.Features.Links.Endpoints;
namespace TrackQrApi.Features.Links.Endpoints;
public class UpdateLinkRequest
{
@@ -66,7 +66,8 @@ public class UpdateLinkEndpoint(AppDbContext db)
var link = await db.ShortLinks
.Include(l => l.Workspace)
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
.FirstOrDefaultAsync(
l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
if (link is null)
{
@@ -88,43 +89,22 @@ public class UpdateLinkEndpoint(AppDbContext db)
}
// Update fields
if (!string.IsNullOrEmpty(req.DestinationUrl))
{
link.DestinationUrl = req.DestinationUrl;
}
if (!string.IsNullOrEmpty(req.DestinationUrl)) link.DestinationUrl = req.DestinationUrl;
if (req.Title != null)
{
link.Title = req.Title;
}
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 (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;
}
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;
}
else if (req.RemoveProject == true) link.ProjectId = null;
link.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
@@ -144,6 +124,6 @@ public class UpdateLinkEndpoint(AppDbContext db)
link.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
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

@@ -1,8 +1,8 @@
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace api.Features.Plans.Services;
namespace TrackQrApi.Features.Plans.Services;
public interface IPlanLimitsService
{
@@ -51,38 +51,41 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
{
[WorkspacePlan.Free] = new PlanLimits(
MaxWorkspaces: 1,
MaxLinksPerWorkspace: 50,
MaxQRCodesPerWorkspace: 25,
MaxDomainsPerWorkspace: 0,
MaxEventsPerMonth: 10_000,
HasCustomDomains: false,
HasPasswordProtection: false,
HasAnalytics: true
1,
50,
25,
0,
10_000,
false,
false,
true
),
[WorkspacePlan.Pro] = new PlanLimits(
MaxWorkspaces: 5,
MaxLinksPerWorkspace: 5_000,
MaxQRCodesPerWorkspace: 1_000,
MaxDomainsPerWorkspace: 3,
MaxEventsPerMonth: 100_000,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
5,
5_000,
1_000,
3,
100_000,
true,
true,
true
),
[WorkspacePlan.Business] = new PlanLimits(
MaxWorkspaces: int.MaxValue,
MaxLinksPerWorkspace: int.MaxValue,
MaxQRCodesPerWorkspace: int.MaxValue,
MaxDomainsPerWorkspace: int.MaxValue,
MaxEventsPerMonth: int.MaxValue,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
int.MaxValue,
int.MaxValue,
int.MaxValue,
int.MaxValue,
int.MaxValue,
true,
true,
true
)
};
public PlanLimits GetLimits(WorkspacePlan plan) => PlanConfigs[plan];
public PlanLimits GetLimits(WorkspacePlan plan)
{
return PlanConfigs[plan];
}
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
{
@@ -114,12 +117,12 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
: WorkspacePlan.Free;
return new UsageStats(
TotalWorkspaces: workspaces.Count,
TotalLinks: totalLinks,
TotalQRCodes: totalQRCodes,
TotalDomains: totalDomains,
EventsThisMonth: eventsThisMonth,
HighestPlan: highestPlan
workspaces.Count,
totalLinks,
totalQRCodes,
totalDomains,
eventsThisMonth,
highestPlan
);
}
@@ -147,13 +150,13 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
var limits = GetLimits(workspace.Plan);
return new WorkspaceUsageStats(
WorkspaceId: workspaceId,
Plan: workspace.Plan,
Links: links,
QRCodes: qrCodes,
Domains: domains,
EventsThisMonth: eventsThisMonth,
Limits: limits
workspaceId,
workspace.Plan,
links,
qrCodes,
domains,
eventsThisMonth,
limits
);
}
@@ -187,4 +190,4 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth;
}
}
}

View File

@@ -1,4 +1,4 @@
namespace api.Features.Projects.Common;
namespace TrackQrApi.Features.Projects.Common;
public record ProjectResponse(
Guid Id,
@@ -12,4 +12,4 @@ public record ProjectResponse(
public record ProjectListResponse(
IEnumerable<ProjectResponse> Projects
);
);

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Projects.Common;
using TrackQrApi.Models;
namespace api.Features.Projects.Endpoints;
namespace TrackQrApi.Features.Projects.Endpoints;
public class CreateProjectRequest
{
@@ -72,4 +72,4 @@ public class CreateProjectEndpoint(AppDbContext db)
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Projects.Endpoints;
namespace TrackQrApi.Features.Projects.Endpoints;
public class DeleteProjectRequest
{
@@ -26,7 +26,8 @@ public class DeleteProjectEndpoint(AppDbContext db)
var project = await db.Projects
.Include(p => p.Workspace)
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
.FirstOrDefaultAsync(
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
if (project is null)
{
@@ -37,6 +38,6 @@ public class DeleteProjectEndpoint(AppDbContext db)
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Projects.Common;
namespace api.Features.Projects.Endpoints;
namespace TrackQrApi.Features.Projects.Endpoints;
public class GetProjectRequest
{
@@ -44,6 +44,6 @@ public class GetProjectEndpoint(AppDbContext db)
return;
}
await HttpContext.Response.SendAsync(project, 200, cancellation: ct);
await HttpContext.Response.SendAsync(project, cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Projects.Common;
namespace api.Features.Projects.Endpoints;
namespace TrackQrApi.Features.Projects.Endpoints;
public class ListProjectsRequest
{
@@ -48,6 +48,6 @@ public class ListProjectsEndpoint(AppDbContext db)
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), cancellation: ct);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Projects.Common;
namespace api.Features.Projects.Endpoints;
namespace TrackQrApi.Features.Projects.Endpoints;
public class UpdateProjectRequest
{
@@ -42,7 +42,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
.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);
.FirstOrDefaultAsync(
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
if (project is null)
{
@@ -64,6 +65,6 @@ public class UpdateProjectEndpoint(AppDbContext db)
project.CreatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,9 +1,7 @@
using System.Text.Json.Serialization;
namespace api.Features.QRCodes.Common;
namespace TrackQrApi.Features.QRCodes.Common;
/// <summary>
/// QR code style configuration stored as JSON
/// QR code style configuration stored as JSON
/// </summary>
public class QRCodeStyle
{
@@ -52,4 +50,4 @@ public record QRCodePreviewResponse(
string Format,
int Width,
int Height
);
);

View File

@@ -1,15 +1,15 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Plans.Services;
using api.Features.QRCodes.Common;
using api.Models;
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 api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class CreateQRCodeRequest
{
@@ -73,9 +73,11 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404,
cancellation: ct);
return;
}
linkSlug = link.Slug;
}
@@ -103,7 +105,8 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
if (asset is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404,
cancellation: ct);
return;
}
@@ -146,4 +149,4 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class DeleteQRCodeRequest
{
@@ -26,7 +26,8 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
var qrCode = await db.QrCodeDesigns
.Include(q => q.Workspace)
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
.FirstOrDefaultAsync(
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
if (qrCode is null)
{
@@ -37,6 +38,6 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
db.QrCodeDesigns.Remove(qrCode);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), cancellation: ct);
}
}
}

View File

@@ -1,14 +1,14 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services;
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 api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class ExportQRCodeRequest
{
@@ -18,7 +18,10 @@ public class ExportQRCodeRequest
public int? Size { get; set; }
}
public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
public class ExportQRCodeEndpoint(
AppDbContext db,
IQrCodeGeneratorService qrGenerator,
IAssetStorageService assetStorage)
: Endpoint<ExportQRCodeRequest>
{
public override void Configure()
@@ -44,7 +47,8 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
if (qrCode.ShortLink is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
cancellation: ct);
return;
}
@@ -63,10 +67,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
if (qrCode.LogoAsset != null)
{
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
}
try
@@ -92,4 +93,4 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
logoStream?.Dispose();
}
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Models;
namespace api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class GetQRCodeAnalyticsRequest
{
@@ -65,16 +65,16 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
.ToListAsync(ct);
var summary = new QRCodeAnalyticsSummary(
TotalScans: events.Count,
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count()
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(
Date: g.Key.ToString("yyyy-MM-dd"),
Scans: g.Count()
g.Key.ToString("yyyy-MM-dd"),
g.Count()
))
.ToList();
@@ -98,14 +98,14 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
.ToDictionary(g => g.Key, g => g.Count());
var response = new QRCodeAnalyticsResponse(
QRCodeId: qrCode.Id,
Name: qrCode.Name,
LinkSlug: qrCode.ShortLink?.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
qrCode.Id,
qrCode.Name,
qrCode.ShortLink?.Slug,
summary,
timeSeries,
deviceBreakdown,
referrerBreakdown,
countryBreakdown
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
@@ -135,4 +135,4 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
return referrer;
}
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.QRCodes.Common;
namespace api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class GetQRCodeRequest
{
@@ -55,6 +55,6 @@ public class GetQRCodeEndpoint(AppDbContext db)
qrCode.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.QRCodes.Common;
namespace api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class ListQRCodesRequest
{
@@ -40,15 +40,9 @@ public class ListQRCodesEndpoint(AppDbContext db)
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.ProjectId.HasValue) query = query.Where(q => q.ProjectId == req.ProjectId.Value);
if (req.ShortLinkId.HasValue)
{
query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
}
if (req.ShortLinkId.HasValue) query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
var qrCodes = await query
.Include(q => q.ShortLink)
@@ -74,6 +68,6 @@ public class ListQRCodesEndpoint(AppDbContext db)
))
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,14 +1,14 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services;
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 api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class PreviewQRCodeRequest
{
@@ -17,7 +17,10 @@ public class PreviewQRCodeRequest
public int? Size { get; set; }
}
public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
public class PreviewQRCodeEndpoint(
AppDbContext db,
IQrCodeGeneratorService qrGenerator,
IAssetStorageService assetStorage)
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
{
public override void Configure()
@@ -43,7 +46,8 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
if (qrCode.ShortLink is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
cancellation: ct);
return;
}
@@ -60,10 +64,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
if (qrCode.LogoAsset != null)
{
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
}
try
@@ -71,17 +72,17 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
var response = new QRCodePreviewResponse(
DataUrl: dataUrl,
Format: "png",
Width: size,
Height: size
dataUrl,
"png",
size,
size
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
finally
{
logoStream?.Dispose();
}
}
}
}

View File

@@ -1,12 +1,12 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.QRCodes.Common;
namespace api.Features.QRCodes.Endpoints;
namespace TrackQrApi.Features.QRCodes.Endpoints;
public class UpdateQRCodeRequest
{
@@ -36,7 +36,8 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
.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);
.FirstOrDefaultAsync(
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
if (qrCode is null)
{
@@ -55,6 +56,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
return;
}
qrCode.ProjectId = req.ProjectId.Value;
}
else if (req.RemoveProject == true)
@@ -63,10 +65,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
}
// Update name if provided
if (!string.IsNullOrWhiteSpace(req.Name))
{
qrCode.Name = req.Name;
}
if (!string.IsNullOrWhiteSpace(req.Name)) qrCode.Name = req.Name;
// Handle logo asset update
if (req.LogoAssetId.HasValue)
@@ -76,9 +75,11 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
if (!assetExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
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);
@@ -89,10 +90,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.LogoAsset = null;
}
if (req.Style != null)
{
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
}
if (req.Style != null) qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
qrCode.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
@@ -114,6 +112,6 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -1,8 +1,9 @@
using api.Features.QRCodes.Common;
using System.Text;
using QRCoder;
using SkiaSharp;
using TrackQrApi.Features.QRCodes.Common;
namespace api.Features.QRCodes.Services;
namespace TrackQrApi.Features.QRCodes.Services;
public interface IQrCodeGeneratorService
{
@@ -23,7 +24,7 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var moduleCount = moduleMatrix.Count;
// Calculate pixels per module based on desired size (accounting for quiet zone)
var totalModules = moduleCount + (style.QuietZone * 2);
var totalModules = moduleCount + style.QuietZone * 2;
var pixelsPerModule = Math.Max(4, size / totalModules);
var actualSize = totalModules * pixelsPerModule;
@@ -47,29 +48,21 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var quietZoneOffset = style.QuietZone * pixelsPerModule;
for (int y = 0; y < moduleCount; y++)
{
for (int x = 0; x < moduleCount; x++)
for (var y = 0; y < moduleCount; y++)
for (var x = 0; x < moduleCount; x++)
if (moduleMatrix[y][x])
{
if (moduleMatrix[y][x])
{
var px = quietZoneOffset + (x * pixelsPerModule);
var py = quietZoneOffset + (y * pixelsPerModule);
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);
// 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);
}
}
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();
@@ -77,15 +70,84 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var qrBytes = data.ToArray();
// If no logo, return the QR code as-is
if (logoStream == null)
{
return qrBytes;
}
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
@@ -126,87 +188,12 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
}
}
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 System.Text.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 (int y = 0; y < moduleCount; y++)
{
for (int 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 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;
}
if (qrBitmap == null || logoBitmap == null) return qrBytes;
// Logo should be about 20% of QR code size
var logoSize = (int)(qrSize * 0.2);
@@ -232,12 +219,9 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
// Resize and draw logo
using var resizedLogo = logoBitmap.Resize(
new SKImageInfo(logoSize, logoSize),
new SKImageInfo(logoSize, logoSize),
new SKSamplingOptions(SKCubicResampler.Mitchell));
if (resizedLogo != null)
{
canvas.DrawBitmap(resizedLogo, logoX, logoY);
}
if (resizedLogo != null) canvas.DrawBitmap(resizedLogo, logoX, logoY);
// Encode to PNG
using var image = surface.Snapshot();
@@ -274,4 +258,4 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
// Default to black
return SKColors.Black;
}
}
}

View File

@@ -1,12 +1,12 @@
using api.Data;
using api.Features.Auth.Common;
using api.Features.Events.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Events.Services;
using TrackQrApi.Models;
namespace api.Features.Redirect.Endpoints;
namespace TrackQrApi.Features.Redirect.Endpoints;
public class PasswordRedirectRequest
{
@@ -101,4 +101,4 @@ public class PasswordRedirectEndpoint(AppDbContext db, IEventTrackingService eve
HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using api.Data;
using api.Features.Auth.Common;
using api.Features.Events.Services;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Events.Services;
using TrackQrApi.Models;
namespace api.Features.Redirect.Endpoints;
namespace TrackQrApi.Features.Redirect.Endpoints;
public class RedirectRequest
{
@@ -78,17 +78,13 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
// 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

@@ -1,4 +1,4 @@
namespace api.Features.Workspaces.Common;
namespace TrackQrApi.Features.Workspaces.Common;
public record WorkspaceResponse(
Guid Id,
@@ -9,4 +9,4 @@ public record WorkspaceResponse(
public record WorkspaceListResponse(
IEnumerable<WorkspaceResponse> Workspaces
);
);

View File

@@ -1,13 +1,13 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Plans.Services;
using api.Features.Workspaces.Common;
using api.Models;
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 api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class CreateWorkspaceRequest
{
@@ -67,4 +67,4 @@ public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLim
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}
}

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class DeleteWorkspaceRequest
{
@@ -35,6 +35,6 @@ public class DeleteWorkspaceEndpoint(AppDbContext db)
db.Workspaces.Remove(workspace);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), 200, cancellation: ct);
await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), cancellation: ct);
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Workspaces.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace api.Features.Workspaces.Endpoints;
namespace TrackQrApi.Features.Workspaces.Endpoints;
public class GetWorkspaceRequest
{
@@ -40,6 +40,6 @@ public class GetWorkspaceEndpoint(AppDbContext db)
return;
}
await HttpContext.Response.SendAsync(workspace, 200, cancellation: ct);
await HttpContext.Response.SendAsync(workspace, cancellation: ct);
}
}
}

Some files were not shown because too many files have changed in this diff Show More