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

View File

@@ -1,20 +1,37 @@
using api.Data;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace Api.Tests; namespace TrackQrApi.Tests;
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{ {
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest") private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
.Build(); .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() private void EnsureContainerStarted()
{ {
@@ -47,40 +64,18 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
// Remove existing DbContext registration // Remove existing DbContext registration
var descriptor = services.SingleOrDefault( var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) if (descriptor != null) services.Remove(descriptor);
{
services.Remove(descriptor);
}
// Add DbContext with Testcontainers connection string // Add DbContext with Testcontainers connection string
services.AddDbContext<AppDbContext>(options => services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString())); 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> /// <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> /// </summary>
public async Task UpgradeWorkspaceToPro(Guid workspaceId) public async Task UpgradeWorkspaceToPro(Guid workspaceId)
{ {
@@ -89,8 +84,8 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
var workspace = await db.Workspaces.FindAsync(workspaceId); var workspace = await db.Workspaces.FindAsync(workspaceId);
if (workspace != null) if (workspace != null)
{ {
workspace.Plan = api.Models.WorkspacePlan.Pro; workspace.Plan = WorkspacePlan.Pro;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; 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 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> : IClassFixture<ApiWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); 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" }); var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict) if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" }); response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>(); var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token; var token = authResult!.Token;
@@ -127,7 +126,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com"); var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _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>(); var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act // Act
@@ -150,8 +150,10 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com"); var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example1.com" }); await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example2.com" }); new { DestinationUrl = "https://example1.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://example2.com" });
// Act // Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links"); 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"); var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _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>(); 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",
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://no-project.com" }); new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://no-project.com" });
// Act // Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}"); var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
@@ -372,7 +377,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
// Act // Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{created!.Id}"); 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}"); var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/links/{created.Id}");
// Assert - All should return NotFound (not exposing existence) // Assert - All should return NotFound (not exposing existence)
@@ -380,4 +386,4 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
} }
} }

View File

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

View File

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

View File

@@ -1,35 +1,32 @@
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
using FluentAssertions; 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 _client = factory.CreateClient();
private readonly HttpClient _noRedirectClient;
public RedirectEndpointTests(ApiWebApplicationFactory factory) private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{ {
_client = factory.CreateClient(); AllowAutoRedirect = false
// Create a client that doesn't follow redirects });
_noRedirectClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{ // Create a client that doesn't follow redirects
AllowAutoRedirect = false
});
}
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email) private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{ {
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" }); var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict) if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" }); response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>(); var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token; var token = authResult!.Token;
@@ -42,7 +39,8 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
return (token, workspaceId); 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 var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{ {
@@ -129,7 +127,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{ {
// Arrange // Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-password@example.com"); 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 // Act
var response = await _client.GetAsync($"/{link.Slug}"); var response = await _client.GetAsync($"/{link.Slug}");
@@ -144,7 +142,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{ {
// Arrange // Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-ok@example.com"); 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 // Act
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" }); var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
@@ -159,7 +157,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{ {
// Arrange // Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-wrong@example.com"); 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 // Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" }); var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
@@ -173,7 +171,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{ {
// Arrange // Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-empty@example.com"); 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 // Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" }); var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
@@ -212,4 +210,4 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect); 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;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Workspaces.Common;
using FluentAssertions; 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> : IClassFixture<ApiWebApplicationFactory>
{ {
private readonly HttpClient _client = factory.CreateClient(); 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" }); var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict) if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" }); response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var result = await response.Content.ReadFromJsonAsync<AuthResponse>(); var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token; var token = result!.Token;
@@ -27,10 +28,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>(); var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id; var workspaceId = workspaces!.Workspaces.First().Id;
if (upgradeToPro) if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
return (token, workspaceId); return (token, workspaceId);
} }
@@ -72,7 +70,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
public async Task CreateWorkspace_WithValidData_ReturnsCreated() public async Task CreateWorkspace_WithValidData_ReturnsCreated()
{ {
// Arrange - upgrade to Pro to allow creating additional workspaces // 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); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act // Act
@@ -152,7 +150,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess() public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
{ {
// Arrange - upgrade to Pro to allow creating additional workspaces // 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); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" }); 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); updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.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 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) : DbContext(options)
{ {
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
@@ -225,4 +225,4 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.ApiKeys.Endpoints; namespace TrackQrApi.Features.ApiKeys.Endpoints;
public class DeleteApiKeyRequest public class DeleteApiKeyRequest
{ {
@@ -17,7 +17,7 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
{ {
public override void Configure() 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) public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
@@ -46,6 +46,6 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
db.ApiKeys.Remove(apiKey); db.ApiKeys.Remove(apiKey);
await db.SaveChangesAsync(ct); 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.ApiKeys.Endpoints; namespace TrackQrApi.Features.ApiKeys.Endpoints;
public class ListApiKeysRequest public class ListApiKeysRequest
{ {
@@ -33,7 +33,7 @@ public class ListApiKeysEndpoint(AppDbContext db)
{ {
public override void Configure() public override void Configure()
{ {
Get("/workspaces/{WorkspaceId}/api-keys"); Get("/workspaces/{WorkspaceId}/TrackQrApi-keys");
} }
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct) public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
@@ -62,11 +62,11 @@ public class ListApiKeysEndpoint(AppDbContext db)
ExpiresAt = k.ExpiresAt, ExpiresAt = k.ExpiresAt,
LastUsedAt = k.LastUsedAt, LastUsedAt = k.LastUsedAt,
CreatedAt = k.CreatedAt, CreatedAt = k.CreatedAt,
IsActive = k.IsActive, IsActive = k.IsActive
}) })
.ToListAsync(ct); .ToListAsync(ct);
var response = new ListApiKeysResponse { ApiKeys = apiKeys }; 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( public record AssetResponse(
Guid Id, Guid Id,
@@ -12,4 +12,4 @@ public record AssetResponse(
public record AssetListResponse( public record AssetListResponse(
IEnumerable<AssetResponse> Assets IEnumerable<AssetResponse> Assets
); );

View File

@@ -1,11 +1,11 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class DeleteAssetRequest
{ {
@@ -27,7 +27,8 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
var asset = await db.Assets var asset = await db.Assets
.Include(a => a.Workspace) .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) if (asset is null)
{ {
@@ -55,6 +56,6 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
db.Assets.Remove(asset); db.Assets.Remove(asset);
await db.SaveChangesAsync(ct); 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 FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class GetAssetRequest
{ {
@@ -50,4 +50,4 @@ public class GetAssetEndpoint(AppDbContext db, IAssetStorageService storage)
await stream.CopyToAsync(HttpContext.Response.Body, ct); await stream.CopyToAsync(HttpContext.Response.Body, ct);
await stream.DisposeAsync(); await stream.DisposeAsync();
} }
} }

View File

@@ -1,12 +1,12 @@
using System.Security.Claims; 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 FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 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 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 FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class UploadAssetRequest
{ {
@@ -39,9 +39,8 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
} }
// Get file from form // Get file from form
IFormFile? file = req.File; var file = req.File;
if (file is null) if (file is null)
{
try try
{ {
file = HttpContext.Request.Form.Files.FirstOrDefault(); file = HttpContext.Request.Form.Files.FirstOrDefault();
@@ -50,7 +49,6 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
{ {
// Form access failed - no file uploaded // Form access failed - no file uploaded
} }
}
if (file is null || file.Length == 0) 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); 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 public interface IAssetStorageService
{ {
@@ -19,10 +19,7 @@ public class LocalAssetStorageService : IAssetStorageService
_basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads"); _basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
// Ensure directory exists // Ensure directory exists
if (!Directory.Exists(_basePath)) if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath);
{
Directory.CreateDirectory(_basePath);
}
} }
public async Task<string> StoreAsync(Stream stream, string filename, string contentType) 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); var filePath = Path.Combine(_basePath, storageKey);
if (!File.Exists(filePath)) if (!File.Exists(filePath)) return Task.FromResult<(Stream, string)?>(null);
{
return Task.FromResult<(Stream, string)?>(null);
}
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
var contentType = GetContentType(storageKey); var contentType = GetContentType(storageKey);
@@ -87,4 +81,4 @@ public class LocalAssetStorageService : IAssetStorageService
_ => "application/octet-stream" _ => "application/octet-stream"
}; };
} }
} }

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints; namespace TrackQrApi.Features.Auth.Endpoints;
public class DeleteAccountRequest 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); await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct);
} }
} }

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Auth.Settings;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; 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 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> : Endpoint<LoginRequest, AuthResponse>
{ {
private readonly JwtSettings _jwtSettings = jwtSettings.Value; 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)) 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; return;
} }
@@ -68,19 +69,19 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
}; };
var token = new JwtSecurityToken( var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer, _jwtSettings.Issuer,
audience: _jwtSettings.Audience, _jwtSettings.Audience,
claims: claims, claims,
expires: expiresAt, expires: expiresAt,
signingCredentials: credentials signingCredentials: credentials
); );
var response = new AuthResponse( var response = new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token), new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt, expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue) 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.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; 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 FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; 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 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)) 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; return;
} }
@@ -122,17 +123,17 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
}; };
var token = new JwtSecurityToken( var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer, _jwtSettings.Issuer,
audience: _jwtSettings.Audience, _jwtSettings.Audience,
claims: claims, claims,
expires: expiresAt, expires: expiresAt,
signingCredentials: credentials signingCredentials: credentials
); );
return new AuthResponse( return new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token), new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt, expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue) new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
); );
} }
} }

View File

@@ -1,13 +1,13 @@
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
{ {
@@ -29,7 +29,8 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
if (user.VerifiedAt != null) 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; return;
} }
@@ -60,4 +61,4 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct); 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 FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints; namespace TrackQrApi.Features.Auth.Endpoints;
public class ResetPasswordRequest public class ResetPasswordRequest
{ {
@@ -82,7 +82,6 @@ public class ResetPasswordEndpoint(AppDbContext db)
await HttpContext.Response.SendAsync( await HttpContext.Response.SendAsync(
new MessageResponse("Password has been reset successfully"), new MessageResponse("Password has been reset successfully"),
200,
cancellation: ct); cancellation: ct);
} }
} }

View File

@@ -1,11 +1,11 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints; namespace TrackQrApi.Features.Auth.Endpoints;
public class UpdateProfileRequest 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); var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
if (emailExists) 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; return;
} }
@@ -65,4 +66,4 @@ public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequ
await HttpContext.Response.SendAsync(response, cancellation: ct); await HttpContext.Response.SendAsync(response, cancellation: ct);
} }
} }

View File

@@ -1,10 +1,10 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Auth.Endpoints; namespace TrackQrApi.Features.Auth.Endpoints;
public class VerifyEmailRequest public class VerifyEmailRequest
{ {
@@ -35,7 +35,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
if (token == null) 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; return;
} }
@@ -43,7 +44,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
{ {
db.EmailVerificationTokens.Remove(token); db.EmailVerificationTokens.Remove(token);
await db.SaveChangesAsync(ct); 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; return;
} }
@@ -56,4 +58,4 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct); 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 public class JwtSettings
{ {
@@ -6,4 +6,4 @@ public class JwtSettings
public required string Issuer { get; set; } public required string Issuer { get; set; }
public required string Audience { get; set; } public required string Audience { get; set; }
public int ExpirationMinutes { get; set; } = 60; 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( public record CheckoutSessionRequest(
Guid WorkspaceId, Guid WorkspaceId,
@@ -20,4 +20,4 @@ public record SubscriptionResponse(
DateTime? CurrentPeriodEnd, DateTime? CurrentPeriodEnd,
bool IsActive, bool IsActive,
bool CancelAtPeriodEnd bool CancelAtPeriodEnd
); );

View File

@@ -1,14 +1,14 @@
using System.Security.Claims; 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 FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; 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> public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
{ {
@@ -56,7 +56,8 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId)) if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
{ {
await HttpContext.Response.SendAsync( 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, 400,
cancellation: ct); cancellation: ct);
return; return;
@@ -84,4 +85,4 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
cancellation: ct); cancellation: ct);
} }
} }
} }

View File

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

View File

@@ -1,12 +1,13 @@
using System.Security.Claims; 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 FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class GetSubscriptionRequest
{ {
@@ -34,7 +35,7 @@ public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeServi
return; return;
} }
var isActive = workspace.Plan != Models.WorkspacePlan.Free; var isActive = workspace.Plan != WorkspacePlan.Free;
var cancelAtPeriodEnd = false; var cancelAtPeriodEnd = false;
// Get live subscription status from Stripe if exists // 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); 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 FastEndpoints;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Stripe; 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( public class StripeWebhookEndpoint(
IStripeService stripeService, IStripeService stripeService,
@@ -38,27 +39,20 @@ public class StripeWebhookEndpoint(
switch (stripeEvent.Type) switch (stripeEvent.Type)
{ {
case "checkout.session.completed": case "checkout.session.completed":
var session = stripeEvent.Data.Object as Stripe.Checkout.Session; var session = stripeEvent.Data.Object as Session;
if (session != null) if (session != null) await stripeService.HandleCheckoutCompletedAsync(session, ct);
{
await stripeService.HandleCheckoutCompletedAsync(session, ct);
}
break; break;
case "customer.subscription.updated": case "customer.subscription.updated":
var updatedSubscription = stripeEvent.Data.Object as Subscription; var updatedSubscription = stripeEvent.Data.Object as Subscription;
if (updatedSubscription != null) if (updatedSubscription != null)
{
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct); await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
}
break; break;
case "customer.subscription.deleted": case "customer.subscription.deleted":
var deletedSubscription = stripeEvent.Data.Object as Subscription; var deletedSubscription = stripeEvent.Data.Object as Subscription;
if (deletedSubscription != null) if (deletedSubscription != null)
{
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct); await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
}
break; break;
case "invoice.payment_failed": case "invoice.payment_failed":
@@ -76,7 +70,8 @@ public class StripeWebhookEndpoint(
catch (StripeException ex) catch (StripeException ex)
{ {
logger.LogError(ex, "Stripe webhook signature verification failed"); 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) catch (Exception ex)
{ {
@@ -84,4 +79,4 @@ public class StripeWebhookEndpoint(
await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct); 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.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Stripe; using Stripe;
using Stripe.Checkout; 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 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<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default); Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default); Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
@@ -23,9 +25,9 @@ public interface IStripeService
public class StripeService : IStripeService public class StripeService : IStripeService
{ {
private readonly ILogger<StripeService> _logger;
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly StripeSettings _settings; private readonly StripeSettings _settings;
private readonly ILogger<StripeService> _logger;
public StripeService( public StripeService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
@@ -51,7 +53,7 @@ public class StripeService : IStripeService
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct) 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 // Get or create Stripe customer
var customerId = user.StripeCustomerId; var customerId = user.StripeCustomerId;
@@ -73,10 +75,7 @@ public class StripeService : IStripeService
} }
var priceId = GetPriceIdForPlan(plan); var priceId = GetPriceIdForPlan(plan);
if (string.IsNullOrEmpty(priceId)) if (string.IsNullOrEmpty(priceId)) throw new InvalidOperationException($"No price configured for plan: {plan}");
{
throw new InvalidOperationException($"No price configured for plan: {plan}");
}
var sessionService = new SessionService(); var sessionService = new SessionService();
var session = await sessionService.CreateAsync(new SessionCreateOptions var session = await sessionService.CreateAsync(new SessionCreateOptions
@@ -122,12 +121,10 @@ public class StripeService : IStripeService
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct) 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)) if (string.IsNullOrEmpty(user.StripeCustomerId))
{
throw new InvalidOperationException("User has no Stripe customer"); throw new InvalidOperationException("User has no Stripe customer");
}
var sessionService = new Stripe.BillingPortal.SessionService(); var sessionService = new Stripe.BillingPortal.SessionService();
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
@@ -202,10 +199,7 @@ public class StripeService : IStripeService
if (!string.IsNullOrEmpty(session.SubscriptionId)) if (!string.IsNullOrEmpty(session.SubscriptionId))
{ {
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct); var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
if (subscription != null) if (subscription != null) workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
{
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
}
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -247,11 +241,9 @@ public class StripeService : IStripeService
// Handle cancellation at period end // Handle cancellation at period end
if (subscription.CancelAtPeriodEnd) if (subscription.CancelAtPeriodEnd)
{
_logger.LogInformation( _logger.LogInformation(
"Workspace {WorkspaceId} subscription will cancel at {EndDate}", "Workspace {WorkspaceId} subscription will cancel at {EndDate}",
workspace.Id, subscription.CurrentPeriodEnd); workspace.Id, subscription.CurrentPeriodEnd);
}
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
@@ -299,4 +291,4 @@ public class StripeService : IStripeService
return WorkspacePlan.Business; return WorkspacePlan.Business;
return WorkspacePlan.Free; return WorkspacePlan.Free;
} }
} }

View File

@@ -1,4 +1,4 @@
namespace api.Features.Billing.Settings; namespace TrackQrApi.Features.Billing.Settings;
public class StripeSettings public class StripeSettings
{ {
@@ -6,4 +6,4 @@ public class StripeSettings
public string WebhookSecret { get; set; } = string.Empty; public string WebhookSecret { get; set; } = string.Empty;
public string ProPriceId { get; set; } = string.Empty; public string ProPriceId { get; set; } = string.Empty;
public string BusinessPriceId { 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( public record DomainResponse(
Guid Id, Guid Id,
@@ -20,4 +20,4 @@ public record DomainVerificationResponse(
bool IsVerified, bool IsVerified,
string Status, string Status,
string? Message string? Message
); );

View File

@@ -1,15 +1,15 @@
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; 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 FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; 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 public class AddDomainRequest
{ {
@@ -70,7 +70,8 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
if (domainExists) 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; return;
} }
@@ -113,4 +114,4 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
{ {
return $"TXT _trakqr-verification {token}"; return $"TXT _trakqr-verification {token}";
} }
} }

View File

@@ -1,10 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Domains.Endpoints; namespace TrackQrApi.Features.Domains.Endpoints;
public class DeleteDomainRequest public class DeleteDomainRequest
{ {
@@ -26,7 +26,8 @@ public class DeleteDomainEndpoint(AppDbContext db)
var domain = await db.Domains var domain = await db.Domains
.Include(d => d.Workspace) .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) if (domain is null)
{ {
@@ -50,6 +51,6 @@ public class DeleteDomainEndpoint(AppDbContext db)
db.Domains.Remove(domain); db.Domains.Remove(domain);
await db.SaveChangesAsync(ct); 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class GetDomainRequest
{ {
@@ -45,6 +45,6 @@ public class GetDomainEndpoint(AppDbContext db)
domain.CreatedAt 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class ListDomainsRequest
{ {
@@ -48,6 +48,6 @@ public class ListDomainsEndpoint(AppDbContext db)
)) ))
.ToListAsync(ct); .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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Domains.Common;
using api.Models;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class VerifyDomainRequest
{ {
@@ -28,7 +28,8 @@ public class VerifyDomainEndpoint(AppDbContext db)
var domain = await db.Domains var domain = await db.Domains
.Include(d => d.Workspace) .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) if (domain is null)
{ {
@@ -46,7 +47,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(), domain.Status.ToString(),
"Domain is already verified" "Domain is already verified"
); );
await HttpContext.Response.SendAsync(alreadyResponse, 200, cancellation: ct); await HttpContext.Response.SendAsync(alreadyResponse, cancellation: ct);
return; return;
} }
@@ -65,7 +66,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(), domain.Status.ToString(),
"Domain verified successfully" "Domain verified successfully"
); );
await HttpContext.Response.SendAsync(successResponse, 200, cancellation: ct); await HttpContext.Response.SendAsync(successResponse, cancellation: ct);
} }
else else
{ {
@@ -76,7 +77,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
domain.Status.ToString(), domain.Status.ToString(),
$"Verification failed. Please add a TXT record for _trakqr-verification.{domain.Hostname} with value: {domain.VerificationToken}" $"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-"); var isVerified = hostname.StartsWith("verified-");
return Task.FromResult(isVerified); return Task.FromResult(isVerified);
} }
} }

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
using System.Net; using System.Net;
using System.Net.Mail; using System.Net.Mail;
using api.Features.Email.Templates;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TrackQrApi.Features.Email.Templates;
namespace api.Features.Email.Services; namespace TrackQrApi.Features.Email.Services;
public class SmtpEmailService : IEmailService public class SmtpEmailService : IEmailService
{ {
private readonly EmailSettings _settings;
private readonly ILogger<SmtpEmailService> _logger; private readonly ILogger<SmtpEmailService> _logger;
private readonly EmailSettings _settings;
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger) 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); _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 verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl); var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
@@ -43,7 +44,8 @@ public class SmtpEmailService : IEmailService
_logger.LogInformation("Welcome email sent to {Email}", toEmail); _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) if (_settings.Smtp == null)
{ {
@@ -76,9 +78,7 @@ public class SmtpEmailService : IEmailService
}; };
if (!string.IsNullOrEmpty(_settings.Smtp.Username)) if (!string.IsNullOrEmpty(_settings.Smtp.Username))
{
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password); client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
}
await client.SendMailAsync(message, ct); await client.SendMailAsync(message, ct);
_logger.LogDebug("Email sent successfully to {Email}", toEmail); _logger.LogDebug("Email sent successfully to {Email}", toEmail);
@@ -89,4 +89,4 @@ public class SmtpEmailService : IEmailService
throw; throw;
} }
} }
} }

View File

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

View File

@@ -1,11 +1,11 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace api.Features.Events.Services; namespace TrackQrApi.Features.Events.Services;
public interface IEventTrackingService public interface IEventTrackingService
{ {
@@ -13,7 +13,10 @@ public interface IEventTrackingService
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context); 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 : IEventTrackingService
{ {
// Dedupe window - same visitor clicking same link within this window counts as one // 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) // Check for forwarded headers (when behind a proxy/load balancer)
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(forwardedFor)) if (!string.IsNullOrEmpty(forwardedFor))
{
// Take the first IP in the chain (client IP) // Take the first IP in the chain (client IP)
return forwardedFor.Split(',')[0].Trim(); return forwardedFor.Split(',')[0].Trim();
}
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
} }
@@ -164,4 +165,4 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
return value.Length <= maxLength ? value : value[..maxLength]; return value.Length <= maxLength ? value : value[..maxLength];
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Net; using System.Net;
using MaxMind.GeoIP2; using MaxMind.GeoIP2;
namespace api.Features.Events.Services; namespace TrackQrApi.Features.Events.Services;
public interface IGeoIpService public interface IGeoIpService
{ {
@@ -10,8 +10,8 @@ public interface IGeoIpService
public class GeoIpService : IGeoIpService, IDisposable public class GeoIpService : IGeoIpService, IDisposable
{ {
private readonly DatabaseReader? _reader;
private readonly ILogger<GeoIpService> _logger; private readonly ILogger<GeoIpService> _logger;
private readonly DatabaseReader? _reader;
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger) public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
{ {
@@ -19,7 +19,6 @@ public class GeoIpService : IGeoIpService, IDisposable
var dbPath = configuration["GeoIP:DatabasePath"]; var dbPath = configuration["GeoIP:DatabasePath"];
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
{
try try
{ {
_reader = new DatabaseReader(dbPath); _reader = new DatabaseReader(dbPath);
@@ -29,11 +28,13 @@ public class GeoIpService : IGeoIpService, IDisposable
{ {
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath); _logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
} }
}
else else
{
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled."); _logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
} }
public void Dispose()
{
_reader?.Dispose();
} }
public string? GetCountryCode(string ipAddress) public string? GetCountryCode(string ipAddress)
@@ -44,20 +45,11 @@ public class GeoIpService : IGeoIpService, IDisposable
try try
{ {
// Handle localhost and private IPs // Handle localhost and private IPs
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) return null;
{
return null;
}
if (!IPAddress.TryParse(ipAddress, out var ip)) if (!IPAddress.TryParse(ipAddress, out var ip)) return null;
{
return null;
}
if (_reader.TryCountry(ip, out var response)) if (_reader.TryCountry(ip, out var response)) return response?.Country?.IsoCode;
{
return response?.Country?.IsoCode;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -87,9 +79,4 @@ public class GeoIpService : IGeoIpService, IDisposable
return false; 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 class LinkDto
{ {
public required Guid Id { get; set; } public required Guid Id { get; set; }
public required string Slug { get; set; } public required string Slug { get; set; }
public required string DestinationUrl { get; set; } public required string DestinationUrl { get; set; }
public required string? Title { get; set; } public required string? Title { get; set; }
public required string Status { get; set; } public required string Status { get; set; }
public int ClickCount { get; set; } public int ClickCount { get; set; }
public DateTimeOffset CreatedAt { 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( public record LinkResponse(
Guid Id, Guid Id,
@@ -18,4 +18,4 @@ public record LinkResponse(
public record LinkListResponse( public record LinkListResponse(
IEnumerable<LinkResponse> Links IEnumerable<LinkResponse> Links
); );

View File

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

View File

@@ -1,12 +1,12 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class BulkCreateLinksRequest
{ {
@@ -59,7 +59,8 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
// Limit bulk creation to 100 links at a time // Limit bulk creation to 100 links at a time
if (req.Links.Count > 100) 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; return;
} }
@@ -70,7 +71,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct); var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
var linkLimit = GetPlanLinkLimit(workspace.Plan); 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]; var item = req.Links[i];
@@ -130,7 +131,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
Title = item.Title, Title = item.Title,
Status = ShortLinkStatus.Active, Status = ShortLinkStatus.Active,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow
}; };
db.ShortLinks.Add(link); db.ShortLinks.Add(link);
@@ -143,7 +144,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
Title = link?.Title, Title = link?.Title,
Status = link.Status.ToString(), Status = link.Status.ToString(),
ClickCount = 0, ClickCount = 0,
CreatedAt = link.CreatedAt, CreatedAt = link.CreatedAt
}); });
} }
@@ -174,4 +175,4 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
_ => 100 // Free plan _ => 100 // Free plan
}; };
} }
} }

View File

@@ -1,14 +1,14 @@
using System.Security.Claims; 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 FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; 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 public class CreateLinkRequest
{ {
@@ -30,7 +30,8 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
RuleFor(x => x.Slug) RuleFor(x => x.Slug)
.MaximumLength(50).WithMessage("Slug must not exceed 50 characters") .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)); .When(x => !string.IsNullOrEmpty(x.Slug));
RuleFor(x => x.Title) RuleFor(x => x.Title)
@@ -107,7 +108,8 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
if (slugExists) 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; return;
} }
} }
@@ -147,4 +149,4 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
await HttpContext.Response.SendAsync(response, 201, cancellation: ct); await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
} }
} }

View File

@@ -1,10 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Links.Endpoints; namespace TrackQrApi.Features.Links.Endpoints;
public class DeleteLinkRequest public class DeleteLinkRequest
{ {
@@ -42,6 +42,6 @@ public class DeleteLinkEndpoint(AppDbContext db)
link.DeletedAt = DateTime.UtcNow; link.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class GetLinkRequest
{ {
@@ -26,7 +26,8 @@ public class GetLinkEndpoint(AppDbContext db)
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks 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( .Select(l => new LinkResponse(
l.Id, l.Id,
l.WorkspaceId, l.WorkspaceId,
@@ -50,6 +51,6 @@ public class GetLinkEndpoint(AppDbContext db)
return; 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class ListLinksRequest
{ {
@@ -41,22 +42,14 @@ public class ListLinksEndpoint(AppDbContext db)
.Where(l => l.WorkspaceId == req.WorkspaceId); .Where(l => l.WorkspaceId == req.WorkspaceId);
// Filter by deleted status (exclude soft-deleted by default) // Filter by deleted status (exclude soft-deleted by default)
if (!req.IncludeDeleted) if (!req.IncludeDeleted) query = query.Where(l => l.DeletedAt == null);
{
query = query.Where(l => l.DeletedAt == null);
}
// Filter by project if specified // Filter by project if specified
if (req.ProjectId.HasValue) if (req.ProjectId.HasValue) query = query.Where(l => l.ProjectId == req.ProjectId.Value);
{
query = query.Where(l => l.ProjectId == req.ProjectId.Value);
}
// Filter by status if specified // 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); query = query.Where(l => l.Status == status);
}
var links = await query var links = await query
.OrderByDescending(l => l.CreatedAt) .OrderByDescending(l => l.CreatedAt)
@@ -77,6 +70,6 @@ public class ListLinksEndpoint(AppDbContext db)
)) ))
.ToListAsync(ct); .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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class RestoreLinkRequest
{ {
@@ -60,6 +60,6 @@ public class RestoreLinkEndpoint(AppDbContext db)
link.DeletedAt 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; 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 public class UpdateLinkRequest
{ {
@@ -66,7 +66,8 @@ public class UpdateLinkEndpoint(AppDbContext db)
var link = await db.ShortLinks var link = await db.ShortLinks
.Include(l => l.Workspace) .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) if (link is null)
{ {
@@ -88,43 +89,22 @@ public class UpdateLinkEndpoint(AppDbContext db)
} }
// Update fields // Update fields
if (!string.IsNullOrEmpty(req.DestinationUrl)) if (!string.IsNullOrEmpty(req.DestinationUrl)) link.DestinationUrl = req.DestinationUrl;
{
link.DestinationUrl = req.DestinationUrl;
}
if (req.Title != null) if (req.Title != null) link.Title = req.Title;
{
link.Title = req.Title;
}
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status)) if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
{
link.Status = status; link.Status = status;
}
if (req.ExpiresAt.HasValue) if (req.ExpiresAt.HasValue) link.ExpiresAt = req.ExpiresAt.Value;
{
link.ExpiresAt = req.ExpiresAt.Value;
}
if (!string.IsNullOrEmpty(req.Password)) if (!string.IsNullOrEmpty(req.Password))
{
link.PasswordHash = BCrypt.Net.BCrypt.HashPassword(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) if (req.ProjectId.HasValue)
{
link.ProjectId = req.ProjectId.Value; 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; link.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -144,6 +124,6 @@ public class UpdateLinkEndpoint(AppDbContext db)
link.UpdatedAt 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 Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace api.Features.Plans.Services; namespace TrackQrApi.Features.Plans.Services;
public interface IPlanLimitsService public interface IPlanLimitsService
{ {
@@ -51,38 +51,41 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new() private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
{ {
[WorkspacePlan.Free] = new PlanLimits( [WorkspacePlan.Free] = new PlanLimits(
MaxWorkspaces: 1, 1,
MaxLinksPerWorkspace: 50, 50,
MaxQRCodesPerWorkspace: 25, 25,
MaxDomainsPerWorkspace: 0, 0,
MaxEventsPerMonth: 10_000, 10_000,
HasCustomDomains: false, false,
HasPasswordProtection: false, false,
HasAnalytics: true true
), ),
[WorkspacePlan.Pro] = new PlanLimits( [WorkspacePlan.Pro] = new PlanLimits(
MaxWorkspaces: 5, 5,
MaxLinksPerWorkspace: 5_000, 5_000,
MaxQRCodesPerWorkspace: 1_000, 1_000,
MaxDomainsPerWorkspace: 3, 3,
MaxEventsPerMonth: 100_000, 100_000,
HasCustomDomains: true, true,
HasPasswordProtection: true, true,
HasAnalytics: true true
), ),
[WorkspacePlan.Business] = new PlanLimits( [WorkspacePlan.Business] = new PlanLimits(
MaxWorkspaces: int.MaxValue, int.MaxValue,
MaxLinksPerWorkspace: int.MaxValue, int.MaxValue,
MaxQRCodesPerWorkspace: int.MaxValue, int.MaxValue,
MaxDomainsPerWorkspace: int.MaxValue, int.MaxValue,
MaxEventsPerMonth: int.MaxValue, int.MaxValue,
HasCustomDomains: true, true,
HasPasswordProtection: true, true,
HasAnalytics: 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) public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
{ {
@@ -114,12 +117,12 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
: WorkspacePlan.Free; : WorkspacePlan.Free;
return new UsageStats( return new UsageStats(
TotalWorkspaces: workspaces.Count, workspaces.Count,
TotalLinks: totalLinks, totalLinks,
TotalQRCodes: totalQRCodes, totalQRCodes,
TotalDomains: totalDomains, totalDomains,
EventsThisMonth: eventsThisMonth, eventsThisMonth,
HighestPlan: highestPlan highestPlan
); );
} }
@@ -147,13 +150,13 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
var limits = GetLimits(workspace.Plan); var limits = GetLimits(workspace.Plan);
return new WorkspaceUsageStats( return new WorkspaceUsageStats(
WorkspaceId: workspaceId, workspaceId,
Plan: workspace.Plan, workspace.Plan,
Links: links, links,
QRCodes: qrCodes, qrCodes,
Domains: domains, domains,
EventsThisMonth: eventsThisMonth, eventsThisMonth,
Limits: limits limits
); );
} }
@@ -187,4 +190,4 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
var usage = await GetWorkspaceUsageAsync(workspaceId, ct); var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth; 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( public record ProjectResponse(
Guid Id, Guid Id,
@@ -12,4 +12,4 @@ public record ProjectResponse(
public record ProjectListResponse( public record ProjectListResponse(
IEnumerable<ProjectResponse> Projects IEnumerable<ProjectResponse> Projects
); );

View File

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

View File

@@ -1,10 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Projects.Endpoints; namespace TrackQrApi.Features.Projects.Endpoints;
public class DeleteProjectRequest public class DeleteProjectRequest
{ {
@@ -26,7 +26,8 @@ public class DeleteProjectEndpoint(AppDbContext db)
var project = await db.Projects var project = await db.Projects
.Include(p => p.Workspace) .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) if (project is null)
{ {
@@ -37,6 +38,6 @@ public class DeleteProjectEndpoint(AppDbContext db)
db.Projects.Remove(project); db.Projects.Remove(project);
await db.SaveChangesAsync(ct); 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class GetProjectRequest
{ {
@@ -44,6 +44,6 @@ public class GetProjectEndpoint(AppDbContext db)
return; 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class ListProjectsRequest
{ {
@@ -48,6 +48,6 @@ public class ListProjectsEndpoint(AppDbContext db)
)) ))
.ToListAsync(ct); .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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Projects.Common;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; 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 public class UpdateProjectRequest
{ {
@@ -42,7 +42,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
.Include(p => p.Workspace) .Include(p => p.Workspace)
.Include(p => p.ShortLinks) .Include(p => p.ShortLinks)
.Include(p => p.QRCodeDesigns) .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) if (project is null)
{ {
@@ -64,6 +65,6 @@ public class UpdateProjectEndpoint(AppDbContext db)
project.CreatedAt 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 TrackQrApi.Features.QRCodes.Common;
namespace api.Features.QRCodes.Common;
/// <summary> /// <summary>
/// QR code style configuration stored as JSON /// QR code style configuration stored as JSON
/// </summary> /// </summary>
public class QRCodeStyle public class QRCodeStyle
{ {
@@ -52,4 +50,4 @@ public record QRCodePreviewResponse(
string Format, string Format,
int Width, int Width,
int Height int Height
); );

View File

@@ -1,15 +1,15 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; 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 FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; 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 public class CreateQRCodeRequest
{ {
@@ -73,9 +73,11 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
if (link is null) 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; return;
} }
linkSlug = link.Slug; linkSlug = link.Slug;
} }
@@ -103,7 +105,8 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
if (asset is null) 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; return;
} }
@@ -146,4 +149,4 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
await HttpContext.Response.SendAsync(response, 201, cancellation: ct); await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
} }
} }

View File

@@ -1,10 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.QRCodes.Endpoints; namespace TrackQrApi.Features.QRCodes.Endpoints;
public class DeleteQRCodeRequest public class DeleteQRCodeRequest
{ {
@@ -26,7 +26,8 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
var qrCode = await db.QrCodeDesigns var qrCode = await db.QrCodeDesigns
.Include(q => q.Workspace) .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) if (qrCode is null)
{ {
@@ -37,6 +38,6 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
db.QrCodeDesigns.Remove(qrCode); db.QrCodeDesigns.Remove(qrCode);
await db.SaveChangesAsync(ct); 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.Security.Claims;
using System.Text.Json; 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 FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class ExportQRCodeRequest
{ {
@@ -18,7 +18,10 @@ public class ExportQRCodeRequest
public int? Size { get; set; } 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> : Endpoint<ExportQRCodeRequest>
{ {
public override void Configure() public override void Configure()
@@ -44,7 +47,8 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
if (qrCode.ShortLink is null) 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; return;
} }
@@ -63,10 +67,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
if (qrCode.LogoAsset != null) if (qrCode.LogoAsset != null)
{ {
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey); var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue) if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
{
logoStream = logoResult.Value.Stream;
}
} }
try try
@@ -92,4 +93,4 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
logoStream?.Dispose(); logoStream?.Dispose();
} }
} }
} }

View File

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

View File

@@ -1,12 +1,12 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class GetQRCodeRequest
{ {
@@ -55,6 +55,6 @@ public class GetQRCodeEndpoint(AppDbContext db)
qrCode.UpdatedAt 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.Security.Claims;
using System.Text.Json; using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class ListQRCodesRequest
{ {
@@ -40,15 +40,9 @@ public class ListQRCodesEndpoint(AppDbContext db)
var query = db.QrCodeDesigns var query = db.QrCodeDesigns
.Where(q => q.WorkspaceId == req.WorkspaceId); .Where(q => q.WorkspaceId == req.WorkspaceId);
if (req.ProjectId.HasValue) if (req.ProjectId.HasValue) query = query.Where(q => q.ProjectId == req.ProjectId.Value);
{
query = query.Where(q => q.ProjectId == req.ProjectId.Value);
}
if (req.ShortLinkId.HasValue) if (req.ShortLinkId.HasValue) query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
{
query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
}
var qrCodes = await query var qrCodes = await query
.Include(q => q.ShortLink) .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.Security.Claims;
using System.Text.Json; 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 FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class PreviewQRCodeRequest
{ {
@@ -17,7 +17,10 @@ public class PreviewQRCodeRequest
public int? Size { get; set; } 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> : Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
{ {
public override void Configure() public override void Configure()
@@ -43,7 +46,8 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
if (qrCode.ShortLink is null) 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; return;
} }
@@ -60,10 +64,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
if (qrCode.LogoAsset != null) if (qrCode.LogoAsset != null)
{ {
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey); var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue) if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
{
logoStream = logoResult.Value.Stream;
}
} }
try try
@@ -71,17 +72,17 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream); var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
var response = new QRCodePreviewResponse( var response = new QRCodePreviewResponse(
DataUrl: dataUrl, dataUrl,
Format: "png", "png",
Width: size, size,
Height: size size
); );
await HttpContext.Response.SendAsync(response, 200, cancellation: ct); await HttpContext.Response.SendAsync(response, cancellation: ct);
} }
finally finally
{ {
logoStream?.Dispose(); logoStream?.Dispose();
} }
} }
} }

View File

@@ -1,12 +1,12 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class UpdateQRCodeRequest
{ {
@@ -36,7 +36,8 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
.Include(q => q.Workspace) .Include(q => q.Workspace)
.Include(q => q.ShortLink) .Include(q => q.ShortLink)
.Include(q => q.LogoAsset) .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) 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); await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
return; return;
} }
qrCode.ProjectId = req.ProjectId.Value; qrCode.ProjectId = req.ProjectId.Value;
} }
else if (req.RemoveProject == true) else if (req.RemoveProject == true)
@@ -63,10 +65,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
} }
// Update name if provided // Update name if provided
if (!string.IsNullOrWhiteSpace(req.Name)) if (!string.IsNullOrWhiteSpace(req.Name)) qrCode.Name = req.Name;
{
qrCode.Name = req.Name;
}
// Handle logo asset update // Handle logo asset update
if (req.LogoAssetId.HasValue) if (req.LogoAssetId.HasValue)
@@ -76,9 +75,11 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
if (!assetExists) 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; return;
} }
qrCode.LogoAssetId = req.LogoAssetId.Value; qrCode.LogoAssetId = req.LogoAssetId.Value;
// Reload the asset for the response // Reload the asset for the response
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct); qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
@@ -89,10 +90,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.LogoAsset = null; qrCode.LogoAsset = null;
} }
if (req.Style != null) if (req.Style != null) qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
{
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
}
qrCode.UpdatedAt = DateTime.UtcNow; qrCode.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -114,6 +112,6 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.UpdatedAt 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 QRCoder;
using SkiaSharp; using SkiaSharp;
using TrackQrApi.Features.QRCodes.Common;
namespace api.Features.QRCodes.Services; namespace TrackQrApi.Features.QRCodes.Services;
public interface IQrCodeGeneratorService public interface IQrCodeGeneratorService
{ {
@@ -23,7 +24,7 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var moduleCount = moduleMatrix.Count; var moduleCount = moduleMatrix.Count;
// Calculate pixels per module based on desired size (accounting for quiet zone) // 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 pixelsPerModule = Math.Max(4, size / totalModules);
var actualSize = totalModules * pixelsPerModule; var actualSize = totalModules * pixelsPerModule;
@@ -47,29 +48,21 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var quietZoneOffset = style.QuietZone * pixelsPerModule; var quietZoneOffset = style.QuietZone * pixelsPerModule;
for (int y = 0; y < moduleCount; y++) for (var y = 0; y < moduleCount; y++)
{ for (var x = 0; x < moduleCount; x++)
for (int 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) // Check if this is part of a finder pattern (eyes)
var isEye = IsFinderPattern(x, y, moduleCount); var isEye = IsFinderPattern(x, y, moduleCount);
if (isEye) if (isEye)
{ DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape); else
} DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
else
{
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
}
}
} }
}
// Encode to PNG // Encode to PNG
using var image = surface.Snapshot(); using var image = surface.Snapshot();
@@ -77,15 +70,84 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
var qrBytes = data.ToArray(); var qrBytes = data.ToArray();
// If no logo, return the QR code as-is // If no logo, return the QR code as-is
if (logoStream == null) if (logoStream == null) return qrBytes;
{
return qrBytes;
}
// Overlay logo on QR code // Overlay logo on QR code
return OverlayLogo(qrBytes, logoStream, actualSize); 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) private static bool IsFinderPattern(int x, int y, int moduleCount)
{ {
// Top-left finder pattern: 0-6, 0-6 // 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) private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
{ {
using var qrBitmap = SKBitmap.Decode(qrBytes); using var qrBitmap = SKBitmap.Decode(qrBytes);
using var logoBitmap = SKBitmap.Decode(logoStream); using var logoBitmap = SKBitmap.Decode(logoStream);
if (qrBitmap == null || logoBitmap == null) if (qrBitmap == null || logoBitmap == null) return qrBytes;
{
return qrBytes;
}
// Logo should be about 20% of QR code size // Logo should be about 20% of QR code size
var logoSize = (int)(qrSize * 0.2); var logoSize = (int)(qrSize * 0.2);
@@ -232,12 +219,9 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
// Resize and draw logo // Resize and draw logo
using var resizedLogo = logoBitmap.Resize( using var resizedLogo = logoBitmap.Resize(
new SKImageInfo(logoSize, logoSize), new SKImageInfo(logoSize, logoSize),
new SKSamplingOptions(SKCubicResampler.Mitchell)); new SKSamplingOptions(SKCubicResampler.Mitchell));
if (resizedLogo != null) if (resizedLogo != null) canvas.DrawBitmap(resizedLogo, logoX, logoY);
{
canvas.DrawBitmap(resizedLogo, logoX, logoY);
}
// Encode to PNG // Encode to PNG
using var image = surface.Snapshot(); using var image = surface.Snapshot();
@@ -274,4 +258,4 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
// Default to black // Default to black
return SKColors.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 FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; 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 public class PasswordRedirectRequest
{ {
@@ -101,4 +101,4 @@ public class PasswordRedirectEndpoint(AppDbContext db, IEventTrackingService eve
HttpContext.Response.Headers.Location = link.DestinationUrl; HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct); 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 FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class RedirectRequest
{ {
@@ -78,17 +78,13 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
// Track event asynchronously (fire and forget) // Track event asynchronously (fire and forget)
// If qr parameter is present, track as scan; otherwise track as click // If qr parameter is present, track as scan; otherwise track as click
if (req.Qr.HasValue) if (req.Qr.HasValue)
{
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext); await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
}
else else
{
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext); await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
}
// Redirect to destination (302 Found) // Redirect to destination (302 Found)
HttpContext.Response.StatusCode = StatusCodes.Status302Found; HttpContext.Response.StatusCode = StatusCodes.Status302Found;
HttpContext.Response.Headers.Location = link.DestinationUrl; HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct); await HttpContext.Response.StartAsync(ct);
} }
} }

View File

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

View File

@@ -1,13 +1,13 @@
using System.Security.Claims; 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 FastEndpoints;
using FluentValidation; 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 public class CreateWorkspaceRequest
{ {
@@ -67,4 +67,4 @@ public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLim
await HttpContext.Response.SendAsync(response, 201, cancellation: ct); await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
} }
} }

View File

@@ -1,10 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
namespace api.Features.Workspaces.Endpoints; namespace TrackQrApi.Features.Workspaces.Endpoints;
public class DeleteWorkspaceRequest public class DeleteWorkspaceRequest
{ {
@@ -35,6 +35,6 @@ public class DeleteWorkspaceEndpoint(AppDbContext db)
db.Workspaces.Remove(workspace); db.Workspaces.Remove(workspace);
await db.SaveChangesAsync(ct); 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 System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Workspaces.Common;
using FastEndpoints; using FastEndpoints;
using Microsoft.EntityFrameworkCore; 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 public class GetWorkspaceRequest
{ {
@@ -40,6 +40,6 @@ public class GetWorkspaceEndpoint(AppDbContext db)
return; 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