chore: correct namespaces and hiearchy
This commit is contained in:
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.gitignore
generated
vendored
Normal file
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.gitignore
generated
vendored
Normal 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
|
||||
1
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.name
generated
Normal file
1
src/TrackApi/.idea/.idea.TrackQrApi/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
TrackQrApi
|
||||
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/dataSources.xml
generated
Normal file
13
src/TrackApi/.idea/.idea.TrackQrApi/.idea/dataSources.xml
generated
Normal 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&user=sa</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
4
src/TrackApi/.idea/.idea.TrackQrApi/.idea/encodings.xml
generated
Normal file
4
src/TrackApi/.idea/.idea.TrackQrApi/.idea/encodings.xml
generated
Normal 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>
|
||||
8
src/TrackApi/.idea/.idea.TrackQrApi/.idea/indexLayout.xml
generated
Normal file
8
src/TrackApi/.idea/.idea.TrackQrApi/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
6
src/TrackApi/.idea/.idea.TrackQrApi/.idea/vcs.xml
generated
Normal file
6
src/TrackApi/.idea/.idea.TrackQrApi/.idea/vcs.xml
generated
Normal 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>
|
||||
@@ -1,36 +1,31 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Analytics.Common;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using TrackQrApi.Features.Analytics.Common;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class AnalyticsEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
||||
public class AnalyticsEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly HttpClient _noRedirectClient;
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
public AnalyticsEndpointTests(ApiWebApplicationFactory factory)
|
||||
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = authResult!.Token;
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
using api.Data;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Testcontainers.PostgreSql;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
|
||||
.Build();
|
||||
|
||||
private bool _containerStarted = false;
|
||||
private bool _containerStarted;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Ensure container is started (might already be started from ConfigureWebHost)
|
||||
EnsureContainerStarted();
|
||||
|
||||
// Run migrations
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
public new async Task DisposeAsync()
|
||||
{
|
||||
await _postgres.DisposeAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
private void EnsureContainerStarted()
|
||||
{
|
||||
@@ -47,40 +64,18 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// Remove existing DbContext registration
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
// Add DbContext with Testcontainers connection string
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(_postgres.GetConnectionString()));
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Ensure container is started (might already be started from ConfigureWebHost)
|
||||
EnsureContainerStarted();
|
||||
|
||||
// Run migrations
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
public new async Task DisposeAsync()
|
||||
{
|
||||
await _postgres.DisposeAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
|
||||
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
|
||||
/// </summary>
|
||||
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
|
||||
{
|
||||
@@ -89,7 +84,7 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
|
||||
var workspace = await db.Workspaces.FindAsync(workspaceId);
|
||||
if (workspace != null)
|
||||
{
|
||||
workspace.Plan = api.Models.WorkspacePlan.Pro;
|
||||
workspace.Plan = WorkspacePlan.Pro;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Assets.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using TrackQrApi.Features.Assets.Common;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class AssetEndpointTests(ApiWebApplicationFactory factory)
|
||||
public class AssetEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
@@ -17,9 +18,7 @@ public class AssetEndpointTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = result!.Token;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using FluentAssertions;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class AuthControllerTests(ApiWebApplicationFactory factory)
|
||||
public class AuthControllerTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
@@ -1,25 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Domains.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
public class DomainEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = true)
|
||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
|
||||
bool upgradeToPro = true)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = result!.Token;
|
||||
|
||||
@@ -29,10 +29,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
var workspaceId = workspaces!.Workspaces.First().Id;
|
||||
|
||||
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
|
||||
if (upgradeToPro)
|
||||
{
|
||||
await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||
}
|
||||
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||
|
||||
return (token, workspaceId);
|
||||
}
|
||||
@@ -45,7 +42,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "example.com" });
|
||||
var response =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "example.com" });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
@@ -81,7 +79,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
|
||||
var response =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
@@ -113,7 +112,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-domain@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "get-test.com" });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "get-test.com" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||
|
||||
// Act
|
||||
@@ -147,7 +147,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-domain@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "to-delete.com" });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "to-delete.com" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||
|
||||
// Act
|
||||
@@ -168,11 +169,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("verify-domain@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "unverified.com" });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "unverified.com" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
||||
var response =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
@@ -189,11 +192,13 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
// The verification mock accepts domains starting with "verified-"
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "verified-test.com" });
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains",
|
||||
new { Hostname = "verified-test.com" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
||||
var response =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
@@ -210,7 +215,8 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token2, _) = await GetAuthAndWorkspaceAsync("domain-user2@example.com");
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/domains", new { Hostname = "user1-domain.com" });
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/domains",
|
||||
new { Hostname = "user1-domain.com" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
|
||||
|
||||
// Act - Try to access as user2
|
||||
@@ -1,35 +1,30 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
|
||||
public class EventTrackingTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly HttpClient _noRedirectClient;
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
public EventTrackingTests(ApiWebApplicationFactory factory)
|
||||
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = authResult!.Token;
|
||||
|
||||
@@ -82,10 +77,7 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
|
||||
|
||||
// Act - Click the same link multiple times rapidly
|
||||
var responses = new List<HttpResponseMessage>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
|
||||
}
|
||||
for (var i = 0; i < 5; i++) responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
|
||||
|
||||
// Assert - All should redirect successfully (deduplication happens silently)
|
||||
responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Redirect);
|
||||
@@ -114,7 +106,8 @@ public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ua-link");
|
||||
|
||||
// Set a custom user agent
|
||||
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
|
||||
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
|
||||
|
||||
// Act
|
||||
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
|
||||
@@ -1,15 +1,16 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Features.Projects.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
||||
public class LinkEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
@@ -18,9 +19,7 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = authResult!.Token;
|
||||
|
||||
@@ -127,7 +126,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
||||
var projectResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
||||
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||
|
||||
// Act
|
||||
@@ -150,8 +150,10 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example1.com" });
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example2.com" });
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||
new { DestinationUrl = "https://example1.com" });
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||
new { DestinationUrl = "https://example2.com" });
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links");
|
||||
@@ -169,11 +171,14 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links-proj@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
|
||||
var projectResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
|
||||
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://no-project.com" });
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||
new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
|
||||
new { DestinationUrl = "https://no-project.com" });
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
|
||||
@@ -372,7 +377,8 @@ public class LinkEndpointTests(ApiWebApplicationFactory factory)
|
||||
|
||||
// Act
|
||||
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{created!.Id}");
|
||||
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
|
||||
var updateResponse =
|
||||
await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
|
||||
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/links/{created.Id}");
|
||||
|
||||
// Assert - All should return NotFound (not exposing existence)
|
||||
@@ -1,14 +1,15 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Projects.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
public class ProjectEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
@@ -17,9 +18,7 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = authResult!.Token;
|
||||
|
||||
@@ -71,7 +70,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
||||
var response =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
@@ -102,7 +102,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||
|
||||
// Act
|
||||
@@ -136,11 +137,13 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-proj@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}", new { Name = "Updated" });
|
||||
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}",
|
||||
new { Name = "Updated" });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
@@ -155,7 +158,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-proj@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||
|
||||
// Act
|
||||
@@ -178,7 +182,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
|
||||
// Create project as user1
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||
|
||||
// Try to access as user2
|
||||
@@ -186,7 +191,8 @@ public class ProjectEndpointTests(ApiWebApplicationFactory factory)
|
||||
|
||||
// Act
|
||||
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/projects/{created!.Id}");
|
||||
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}", new { Name = "Hacked" });
|
||||
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}",
|
||||
new { Name = "Hacked" });
|
||||
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/projects/{created.Id}");
|
||||
|
||||
// Assert - All should return NotFound (not exposing existence)
|
||||
@@ -1,15 +1,16 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
public class QrCodeEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
@@ -18,9 +19,7 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = authResult!.Token;
|
||||
|
||||
@@ -139,7 +138,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var link = await CreateLinkAsync(workspaceId, "qr-get-link");
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||
|
||||
// Act
|
||||
@@ -173,7 +173,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var link = await CreateLinkAsync(workspaceId, "qr-update-link");
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||
|
||||
// Act
|
||||
@@ -201,7 +202,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var link = await CreateLinkAsync(workspaceId, "qr-delete-link");
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||
|
||||
// Act
|
||||
@@ -223,7 +225,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var link = await CreateLinkAsync(workspaceId, "qr-preview-link");
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||
|
||||
// Act
|
||||
@@ -244,7 +247,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var link = await CreateLinkAsync(workspaceId, "qr-export-png-link");
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||
|
||||
// Act
|
||||
@@ -263,7 +267,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var link = await CreateLinkAsync(workspaceId, "qr-export-svg-link");
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||
|
||||
// Act
|
||||
@@ -280,7 +285,8 @@ public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
|
||||
// Arrange - Create two users
|
||||
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("qr-user1@example.com");
|
||||
var link = await CreateLinkAsync(workspaceId1, "qr-user1-link");
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var createResponse =
|
||||
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
|
||||
|
||||
var (token2, _) = await SetupAuthAndWorkspaceAsync("qr-user2@example.com");
|
||||
@@ -1,35 +1,32 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
||||
public class RedirectEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly HttpClient _noRedirectClient;
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
public RedirectEndpointTests(ApiWebApplicationFactory factory)
|
||||
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
// Create a client that doesn't follow redirects
|
||||
_noRedirectClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
// Create a client that doesn't follow redirects
|
||||
|
||||
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = authResult!.Token;
|
||||
|
||||
@@ -42,7 +39,8 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
||||
return (token, workspaceId);
|
||||
}
|
||||
|
||||
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null, string? password = null)
|
||||
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null,
|
||||
string? password = null)
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
|
||||
{
|
||||
@@ -129,7 +127,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
// Arrange
|
||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-password@example.com");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", password: "secret123");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", "secret123");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/{link.Slug}");
|
||||
@@ -144,7 +142,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
// Arrange
|
||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-ok@example.com");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", password: "secret123");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", "secret123");
|
||||
|
||||
// Act
|
||||
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
|
||||
@@ -159,7 +157,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
// Arrange
|
||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-wrong@example.com");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", password: "secret123");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", "secret123");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
|
||||
@@ -173,7 +171,7 @@ public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
// Arrange
|
||||
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-empty@example.com");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", password: "secret123");
|
||||
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", "secret123");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
|
||||
28
src/TrackApi/TrackQrApi.Tests/TrackQrApi.Tests.csproj
Normal file
28
src/TrackApi/TrackQrApi.Tests/TrackQrApi.Tests.csproj
Normal 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>
|
||||
@@ -1,24 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FluentAssertions;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace Api.Tests;
|
||||
namespace TrackQrApi.Tests;
|
||||
|
||||
public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
public class WorkspaceEndpointTests(
|
||||
ApiWebApplicationFactory factory)
|
||||
: IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = false)
|
||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
|
||||
bool upgradeToPro = false)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
var token = result!.Token;
|
||||
|
||||
@@ -27,10 +28,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
|
||||
var workspaceId = workspaces!.Workspaces.First().Id;
|
||||
|
||||
if (upgradeToPro)
|
||||
{
|
||||
await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||
}
|
||||
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||
|
||||
return (token, workspaceId);
|
||||
}
|
||||
@@ -72,7 +70,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
|
||||
{
|
||||
// Arrange - upgrade to Pro to allow creating additional workspaces
|
||||
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", upgradeToPro: true);
|
||||
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", true);
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
// Act
|
||||
@@ -152,7 +150,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
|
||||
{
|
||||
// Arrange - upgrade to Pro to allow creating additional workspaces
|
||||
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", upgradeToPro: true);
|
||||
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", true);
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });
|
||||
4
src/TrackApi/TrackQrApi.slnx
Normal file
4
src/TrackApi/TrackQrApi.slnx
Normal file
@@ -0,0 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="TrackQrApi.Tests\TrackQrApi.Tests.csproj"/>
|
||||
<Project Path="TrackQrApi\TrackQrApi.csproj"/>
|
||||
</Solution>
|
||||
@@ -1,7 +1,7 @@
|
||||
using api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Data;
|
||||
namespace TrackQrApi.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
: DbContext(options)
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Analytics.Common;
|
||||
namespace TrackQrApi.Features.Analytics.Common;
|
||||
|
||||
public record AnalyticsSummary(
|
||||
int TotalClicks,
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Analytics.Common;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Analytics.Common;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Analytics.Endpoints;
|
||||
namespace TrackQrApi.Features.Analytics.Endpoints;
|
||||
|
||||
public class LinkAnalyticsRequest
|
||||
{
|
||||
@@ -59,26 +59,20 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
var eventsQuery = db.Events
|
||||
.Where(e => e.ShortLinkId == req.Id);
|
||||
|
||||
if (startDate.HasValue)
|
||||
{
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
}
|
||||
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
|
||||
if (endDate.HasValue)
|
||||
{
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
}
|
||||
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
|
||||
var events = await eventsQuery.ToListAsync(ct);
|
||||
var totalEvents = events.Count;
|
||||
|
||||
// Build summary
|
||||
var summary = new AnalyticsSummary(
|
||||
TotalClicks: events.Count(e => e.Type == EventType.Click),
|
||||
TotalScans: events.Count(e => e.Type == EventType.Scan),
|
||||
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
|
||||
FirstEvent: events.MinBy(e => e.Timestamp)?.Timestamp,
|
||||
LastEvent: events.MaxBy(e => e.Timestamp)?.Timestamp
|
||||
events.Count(e => e.Type == EventType.Click),
|
||||
events.Count(e => e.Type == EventType.Scan),
|
||||
events.Select(e => e.IpHash).Distinct().Count(),
|
||||
events.MinBy(e => e.Timestamp)?.Timestamp,
|
||||
events.MaxBy(e => e.Timestamp)?.Timestamp
|
||||
);
|
||||
|
||||
// Build time series
|
||||
@@ -86,9 +80,9 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
.GroupBy(e => e.Timestamp.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new TimeSeriesPoint(
|
||||
Date: g.Key,
|
||||
Clicks: g.Count(e => e.Type == EventType.Click),
|
||||
Scans: g.Count(e => e.Type == EventType.Scan)
|
||||
g.Key,
|
||||
g.Count(e => e.Type == EventType.Click),
|
||||
g.Count(e => e.Type == EventType.Scan)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
@@ -131,16 +125,16 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
.ToList();
|
||||
|
||||
var response = new LinkAnalyticsResponse(
|
||||
LinkId: link.Id,
|
||||
Slug: link.Slug,
|
||||
Summary: summary,
|
||||
TimeSeries: timeSeries,
|
||||
DeviceBreakdown: deviceBreakdown,
|
||||
ReferrerBreakdown: referrerBreakdown,
|
||||
CountryBreakdown: countryBreakdown
|
||||
link.Id,
|
||||
link.Slug,
|
||||
summary,
|
||||
timeSeries,
|
||||
deviceBreakdown,
|
||||
referrerBreakdown,
|
||||
countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
|
||||
private static DateTime? GetStartDate(string? period)
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Analytics.Common;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Analytics.Common;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Analytics.Endpoints;
|
||||
namespace TrackQrApi.Features.Analytics.Endpoints;
|
||||
|
||||
public class WorkspaceAnalyticsRequest
|
||||
{
|
||||
@@ -56,26 +56,20 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
var eventsQuery = db.Events
|
||||
.Where(e => e.WorkspaceId == req.WorkspaceId);
|
||||
|
||||
if (startDate.HasValue)
|
||||
{
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
}
|
||||
if (startDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
|
||||
if (endDate.HasValue)
|
||||
{
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
}
|
||||
if (endDate.HasValue) eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
|
||||
var events = await eventsQuery.ToListAsync(ct);
|
||||
var totalEvents = events.Count;
|
||||
|
||||
// Get summary
|
||||
var summary = new AnalyticsSummary(
|
||||
TotalClicks: events.Count(e => e.Type == EventType.Click),
|
||||
TotalScans: events.Count(e => e.Type == EventType.Scan),
|
||||
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
|
||||
FirstEvent: events.Count > 0 ? events.Min(e => e.Timestamp) : null,
|
||||
LastEvent: events.Count > 0 ? events.Max(e => e.Timestamp) : null
|
||||
events.Count(e => e.Type == EventType.Click),
|
||||
events.Count(e => e.Type == EventType.Scan),
|
||||
events.Select(e => e.IpHash).Distinct().Count(),
|
||||
events.Count > 0 ? events.Min(e => e.Timestamp) : null,
|
||||
events.Count > 0 ? events.Max(e => e.Timestamp) : null
|
||||
);
|
||||
|
||||
// Get time series
|
||||
@@ -83,9 +77,9 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
.GroupBy(e => e.Timestamp.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new TimeSeriesPoint(
|
||||
Date: g.Key,
|
||||
Clicks: g.Count(e => e.Type == EventType.Click),
|
||||
Scans: g.Count(e => e.Type == EventType.Scan)
|
||||
g.Key,
|
||||
g.Count(e => e.Type == EventType.Click),
|
||||
g.Count(e => e.Type == EventType.Scan)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
@@ -146,15 +140,15 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
.ToList();
|
||||
|
||||
var response = new WorkspaceAnalyticsResponse(
|
||||
Summary: summary,
|
||||
TimeSeries: timeSeries,
|
||||
TopLinks: topLinks,
|
||||
DeviceBreakdown: deviceBreakdown,
|
||||
ReferrerBreakdown: referrerBreakdown,
|
||||
CountryBreakdown: countryBreakdown
|
||||
summary,
|
||||
timeSeries,
|
||||
topLinks,
|
||||
deviceBreakdown,
|
||||
referrerBreakdown,
|
||||
countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
|
||||
private static DateTime? GetStartDate(string? period)
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Models;
|
||||
using System.Text;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.ApiKeys.Endpoints;
|
||||
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class CreateApiKeyRequest
|
||||
{
|
||||
@@ -32,7 +33,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/api-keys");
|
||||
Post("/workspaces/{WorkspaceId}/TrackQrApi-keys");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
|
||||
@@ -53,7 +54,8 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
||||
var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct);
|
||||
if (existingCount >= 10)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,7 +75,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
||||
Scopes = req.Scopes,
|
||||
ExpiresAt = req.ExpiresAt,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(apiKey);
|
||||
@@ -87,7 +89,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
||||
KeyPrefix = keyPrefix,
|
||||
Scopes = apiKey.Scopes,
|
||||
ExpiresAt = apiKey.ExpiresAt,
|
||||
CreatedAt = apiKey.CreatedAt,
|
||||
CreatedAt = apiKey.CreatedAt
|
||||
};
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
@@ -95,7 +97,7 @@ public class CreateApiKeyEndpoint(AppDbContext db)
|
||||
|
||||
private static string ComputeSha256Hash(string input)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLower();
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.ApiKeys.Endpoints;
|
||||
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class DeleteApiKeyRequest
|
||||
{
|
||||
@@ -17,7 +17,7 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/api-keys/{Id}");
|
||||
Delete("/workspaces/{WorkspaceId}/TrackQrApi-keys/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
|
||||
@@ -46,6 +46,6 @@ public class DeleteApiKeyEndpoint(AppDbContext db)
|
||||
db.ApiKeys.Remove(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.ApiKeys.Endpoints;
|
||||
namespace TrackQrApi.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class ListApiKeysRequest
|
||||
{
|
||||
@@ -33,7 +33,7 @@ public class ListApiKeysEndpoint(AppDbContext db)
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/api-keys");
|
||||
Get("/workspaces/{WorkspaceId}/TrackQrApi-keys");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
|
||||
@@ -62,11 +62,11 @@ public class ListApiKeysEndpoint(AppDbContext db)
|
||||
ExpiresAt = k.ExpiresAt,
|
||||
LastUsedAt = k.LastUsedAt,
|
||||
CreatedAt = k.CreatedAt,
|
||||
IsActive = k.IsActive,
|
||||
IsActive = k.IsActive
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Assets.Common;
|
||||
namespace TrackQrApi.Features.Assets.Common;
|
||||
|
||||
public record AssetResponse(
|
||||
Guid Id,
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Assets.Endpoints;
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class DeleteAssetRequest
|
||||
{
|
||||
@@ -27,7 +27,8 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
|
||||
var asset = await db.Assets
|
||||
.Include(a => a.Workspace)
|
||||
.FirstOrDefaultAsync(a => a.Id == req.Id && a.WorkspaceId == req.WorkspaceId && a.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
a => a.Id == req.Id && a.WorkspaceId == req.WorkspaceId && a.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
@@ -55,6 +56,6 @@ public class DeleteAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
db.Assets.Remove(asset);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Asset deleted"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Asset deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using api.Data;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Assets.Endpoints;
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class GetAssetRequest
|
||||
{
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Assets.Common;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Common;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Assets.Endpoints;
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class ListAssetsRequest
|
||||
{
|
||||
@@ -52,6 +52,6 @@ public class ListAssetsEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
))
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Assets.Common;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Common;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Assets.Endpoints;
|
||||
namespace TrackQrApi.Features.Assets.Endpoints;
|
||||
|
||||
public class UploadAssetRequest
|
||||
{
|
||||
@@ -39,9 +39,8 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
}
|
||||
|
||||
// Get file from form
|
||||
IFormFile? file = req.File;
|
||||
var file = req.File;
|
||||
if (file is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
file = HttpContext.Request.Form.Files.FirstOrDefault();
|
||||
@@ -50,7 +49,6 @@ public class UploadAssetEndpoint(AppDbContext db, IAssetStorageService storage)
|
||||
{
|
||||
// Form access failed - no file uploaded
|
||||
}
|
||||
}
|
||||
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Assets.Services;
|
||||
namespace TrackQrApi.Features.Assets.Services;
|
||||
|
||||
public interface IAssetStorageService
|
||||
{
|
||||
@@ -19,10 +19,7 @@ public class LocalAssetStorageService : IAssetStorageService
|
||||
_basePath = configuration["Storage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
|
||||
// Ensure directory exists
|
||||
if (!Directory.Exists(_basePath))
|
||||
{
|
||||
Directory.CreateDirectory(_basePath);
|
||||
}
|
||||
if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath);
|
||||
}
|
||||
|
||||
public async Task<string> StoreAsync(Stream stream, string filename, string contentType)
|
||||
@@ -44,10 +41,7 @@ public class LocalAssetStorageService : IAssetStorageService
|
||||
{
|
||||
var filePath = Path.Combine(_basePath, storageKey);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return Task.FromResult<(Stream, string)?>(null);
|
||||
}
|
||||
if (!File.Exists(filePath)) return Task.FromResult<(Stream, string)?>(null);
|
||||
|
||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
var contentType = GetContentType(storageKey);
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Auth.Common;
|
||||
namespace TrackQrApi.Features.Auth.Common;
|
||||
|
||||
public record AuthResponse(
|
||||
string Token,
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ChangePasswordRequest
|
||||
{
|
||||
@@ -46,7 +46,8 @@ public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRe
|
||||
// Verify current password
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class DeleteAccountRequest
|
||||
{
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Email.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Email.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ForgotPasswordRequest
|
||||
{
|
||||
@@ -46,10 +46,7 @@ public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
|
||||
.Where(t => t.UserId == user.Id && !t.Used)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var token in existingTokens)
|
||||
{
|
||||
token.Used = true;
|
||||
}
|
||||
foreach (var token in existingTokens) token.Used = true;
|
||||
|
||||
// Generate new token
|
||||
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
@@ -87,7 +84,6 @@ public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
|
||||
// Always return success to prevent email enumeration
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("If the email exists, a reset link will be sent"),
|
||||
200,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public record ProfileResponse(
|
||||
Guid Id,
|
||||
@@ -1,16 +1,16 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Auth.Settings;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Auth.Settings;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
@@ -50,7 +50,8 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
|
||||
|
||||
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,19 +69,19 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _jwtSettings.Issuer,
|
||||
audience: _jwtSettings.Audience,
|
||||
claims: claims,
|
||||
_jwtSettings.Issuer,
|
||||
_jwtSettings.Audience,
|
||||
claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
var response = new AuthResponse(
|
||||
Token: new JwtSecurityTokenHandler().WriteToken(token),
|
||||
ExpiresAt: expiresAt,
|
||||
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||
new JwtSecurityTokenHandler().WriteToken(token),
|
||||
expiresAt,
|
||||
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@ using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Auth.Settings;
|
||||
using api.Features.Email.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Auth.Settings;
|
||||
using TrackQrApi.Features.Email.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class RegisterRequest
|
||||
{
|
||||
@@ -55,7 +55,8 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.Email == normalizedEmail, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,17 +123,17 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _jwtSettings.Issuer,
|
||||
audience: _jwtSettings.Audience,
|
||||
claims: claims,
|
||||
_jwtSettings.Issuer,
|
||||
_jwtSettings.Audience,
|
||||
claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
return new AuthResponse(
|
||||
Token: new JwtSecurityTokenHandler().WriteToken(token),
|
||||
ExpiresAt: expiresAt,
|
||||
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||
new JwtSecurityTokenHandler().WriteToken(token),
|
||||
expiresAt,
|
||||
new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Email.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Email.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
|
||||
{
|
||||
@@ -29,7 +29,8 @@ public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailServ
|
||||
|
||||
if (user.VerifiedAt != null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class ResetPasswordRequest
|
||||
{
|
||||
@@ -82,7 +82,6 @@ public class ResetPasswordEndpoint(AppDbContext db)
|
||||
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Password has been reset successfully"),
|
||||
200,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class UpdateProfileRequest
|
||||
{
|
||||
@@ -46,7 +46,8 @@ public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequ
|
||||
var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
|
||||
if (emailExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
namespace TrackQrApi.Features.Auth.Endpoints;
|
||||
|
||||
public class VerifyEmailRequest
|
||||
{
|
||||
@@ -35,7 +35,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
|
||||
|
||||
if (token == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,7 +44,8 @@ public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
|
||||
{
|
||||
db.EmailVerificationTokens.Remove(token);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Auth.Settings;
|
||||
namespace TrackQrApi.Features.Auth.Settings;
|
||||
|
||||
public class JwtSettings
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Billing.Common;
|
||||
namespace TrackQrApi.Features.Billing.Common;
|
||||
|
||||
public record CheckoutSessionRequest(
|
||||
Guid WorkspaceId,
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Billing.Common;
|
||||
using api.Features.Billing.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Billing.Common;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
|
||||
{
|
||||
@@ -56,7 +56,8 @@ public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService strip
|
||||
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Workspace already has an active subscription. Use the billing portal to manage it."),
|
||||
new MessageResponse(
|
||||
"Workspace already has an active subscription. Use the billing portal to manage it."),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Billing.Common;
|
||||
using api.Features.Billing.Services;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Billing.Common;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
|
||||
{
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Billing.Common;
|
||||
using api.Features.Billing.Services;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Billing.Common;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class GetSubscriptionRequest
|
||||
{
|
||||
@@ -34,7 +35,7 @@ public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeServi
|
||||
return;
|
||||
}
|
||||
|
||||
var isActive = workspace.Plan != Models.WorkspacePlan.Free;
|
||||
var isActive = workspace.Plan != WorkspacePlan.Free;
|
||||
var cancelAtPeriodEnd = false;
|
||||
|
||||
// Get live subscription status from Stripe if exists
|
||||
@@ -1,10 +1,11 @@
|
||||
using api.Features.Billing.Services;
|
||||
using api.Features.Billing.Settings;
|
||||
using FastEndpoints;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using TrackQrApi.Features.Billing.Services;
|
||||
using TrackQrApi.Features.Billing.Settings;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
namespace TrackQrApi.Features.Billing.Endpoints;
|
||||
|
||||
public class StripeWebhookEndpoint(
|
||||
IStripeService stripeService,
|
||||
@@ -38,27 +39,20 @@ public class StripeWebhookEndpoint(
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case "checkout.session.completed":
|
||||
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
||||
if (session != null)
|
||||
{
|
||||
await stripeService.HandleCheckoutCompletedAsync(session, ct);
|
||||
}
|
||||
var session = stripeEvent.Data.Object as Session;
|
||||
if (session != null) await stripeService.HandleCheckoutCompletedAsync(session, ct);
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
var updatedSubscription = stripeEvent.Data.Object as Subscription;
|
||||
if (updatedSubscription != null)
|
||||
{
|
||||
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
var deletedSubscription = stripeEvent.Data.Object as Subscription;
|
||||
if (deletedSubscription != null)
|
||||
{
|
||||
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "invoice.payment_failed":
|
||||
@@ -76,7 +70,8 @@ public class StripeWebhookEndpoint(
|
||||
catch (StripeException ex)
|
||||
{
|
||||
logger.LogError(ex, "Stripe webhook signature verification failed");
|
||||
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400,
|
||||
cancellation: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1,16 +1,18 @@
|
||||
using api.Data;
|
||||
using api.Features.Billing.Settings;
|
||||
using api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Billing.Settings;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Billing.Services;
|
||||
namespace TrackQrApi.Features.Billing.Services;
|
||||
|
||||
public interface IStripeService
|
||||
{
|
||||
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl, string cancelUrl, CancellationToken ct = default);
|
||||
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl,
|
||||
string cancelUrl, CancellationToken ct = default);
|
||||
|
||||
Task<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
|
||||
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||
@@ -23,9 +25,9 @@ public interface IStripeService
|
||||
|
||||
public class StripeService : IStripeService
|
||||
{
|
||||
private readonly ILogger<StripeService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly StripeSettings _settings;
|
||||
private readonly ILogger<StripeService> _logger;
|
||||
|
||||
public StripeService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
@@ -51,7 +53,7 @@ public class StripeService : IStripeService
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct)
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
|
||||
// Get or create Stripe customer
|
||||
var customerId = user.StripeCustomerId;
|
||||
@@ -73,10 +75,7 @@ public class StripeService : IStripeService
|
||||
}
|
||||
|
||||
var priceId = GetPriceIdForPlan(plan);
|
||||
if (string.IsNullOrEmpty(priceId))
|
||||
{
|
||||
throw new InvalidOperationException($"No price configured for plan: {plan}");
|
||||
}
|
||||
if (string.IsNullOrEmpty(priceId)) throw new InvalidOperationException($"No price configured for plan: {plan}");
|
||||
|
||||
var sessionService = new SessionService();
|
||||
var session = await sessionService.CreateAsync(new SessionCreateOptions
|
||||
@@ -122,12 +121,10 @@ public class StripeService : IStripeService
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct)
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
|
||||
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
||||
{
|
||||
throw new InvalidOperationException("User has no Stripe customer");
|
||||
}
|
||||
|
||||
var sessionService = new Stripe.BillingPortal.SessionService();
|
||||
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
|
||||
@@ -202,10 +199,7 @@ public class StripeService : IStripeService
|
||||
if (!string.IsNullOrEmpty(session.SubscriptionId))
|
||||
{
|
||||
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
|
||||
if (subscription != null)
|
||||
{
|
||||
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
||||
}
|
||||
if (subscription != null) workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -247,11 +241,9 @@ public class StripeService : IStripeService
|
||||
|
||||
// Handle cancellation at period end
|
||||
if (subscription.CancelAtPeriodEnd)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
|
||||
workspace.Id, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Billing.Settings;
|
||||
namespace TrackQrApi.Features.Billing.Settings;
|
||||
|
||||
public class StripeSettings
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Domains.Common;
|
||||
namespace TrackQrApi.Features.Domains.Common;
|
||||
|
||||
public record DomainResponse(
|
||||
Guid Id,
|
||||
@@ -1,15 +1,15 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Domains.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Domains.Endpoints;
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class AddDomainRequest
|
||||
{
|
||||
@@ -70,7 +70,8 @@ public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
|
||||
if (domainExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain is already registered"), 409, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain is already registered"), 409,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Domains.Endpoints;
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class DeleteDomainRequest
|
||||
{
|
||||
@@ -26,7 +26,8 @@ public class DeleteDomainEndpoint(AppDbContext db)
|
||||
|
||||
var domain = await db.Domains
|
||||
.Include(d => d.Workspace)
|
||||
.FirstOrDefaultAsync(d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (domain is null)
|
||||
{
|
||||
@@ -50,6 +51,6 @@ public class DeleteDomainEndpoint(AppDbContext db)
|
||||
db.Domains.Remove(domain);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain deleted"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Domain deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Domains.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
|
||||
namespace api.Features.Domains.Endpoints;
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class GetDomainRequest
|
||||
{
|
||||
@@ -45,6 +45,6 @@ public class GetDomainEndpoint(AppDbContext db)
|
||||
domain.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Domains.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
|
||||
namespace api.Features.Domains.Endpoints;
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class ListDomainsRequest
|
||||
{
|
||||
@@ -48,6 +48,6 @@ public class ListDomainsEndpoint(AppDbContext db)
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new DomainListResponse(domains), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new DomainListResponse(domains), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Domains.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Domains.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Domains.Endpoints;
|
||||
namespace TrackQrApi.Features.Domains.Endpoints;
|
||||
|
||||
public class VerifyDomainRequest
|
||||
{
|
||||
@@ -28,7 +28,8 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
||||
|
||||
var domain = await db.Domains
|
||||
.Include(d => d.Workspace)
|
||||
.FirstOrDefaultAsync(d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
d => d.Id == req.Id && d.WorkspaceId == req.WorkspaceId && d.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (domain is null)
|
||||
{
|
||||
@@ -46,7 +47,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
||||
domain.Status.ToString(),
|
||||
"Domain is already verified"
|
||||
);
|
||||
await HttpContext.Response.SendAsync(alreadyResponse, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(alreadyResponse, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
||||
domain.Status.ToString(),
|
||||
"Domain verified successfully"
|
||||
);
|
||||
await HttpContext.Response.SendAsync(successResponse, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(successResponse, cancellation: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -76,7 +77,7 @@ public class VerifyDomainEndpoint(AppDbContext db)
|
||||
domain.Status.ToString(),
|
||||
$"Verification failed. Please add a TXT record for _trakqr-verification.{domain.Hostname} with value: {domain.VerificationToken}"
|
||||
);
|
||||
await HttpContext.Response.SendAsync(failedResponse, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(failedResponse, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
using api.Features.Email.Templates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TrackQrApi.Features.Email.Templates;
|
||||
|
||||
namespace api.Features.Email.Services;
|
||||
namespace TrackQrApi.Features.Email.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Development email service that logs emails to console instead of sending them.
|
||||
/// Useful for testing without a real SMTP server.
|
||||
/// Development email service that logs emails to console instead of sending them.
|
||||
/// Useful for testing without a real SMTP server.
|
||||
/// </summary>
|
||||
public class ConsoleEmailService : IEmailService
|
||||
public class ConsoleEmailService(
|
||||
IOptions<EmailSettings> settings,
|
||||
ILogger<ConsoleEmailService> logger)
|
||||
: IEmailService
|
||||
{
|
||||
private readonly EmailSettings _settings;
|
||||
private readonly ILogger<ConsoleEmailService> _logger;
|
||||
|
||||
public ConsoleEmailService(IOptions<EmailSettings> settings, ILogger<ConsoleEmailService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
private readonly EmailSettings _settings = settings.Value;
|
||||
|
||||
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
|
||||
{
|
||||
@@ -47,14 +43,15 @@ public class ConsoleEmailService : IEmailService
|
||||
|
||||
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
|
||||
{
|
||||
_logger.LogInformation($"""
|
||||
logger.LogInformation($"""
|
||||
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ EMAIL (Console Mode) ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
To: {toEmail}
|
||||
Subject: {subject}
|
||||
Action URL: {actionUrl}
|
||||
""");
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ EMAIL (Console Mode) ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
To: {toEmail}
|
||||
Subject: {subject}
|
||||
Action URL: {actionUrl}
|
||||
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Email.Services;
|
||||
namespace TrackQrApi.Features.Email.Services;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using api.Features.Email.Templates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TrackQrApi.Features.Email.Templates;
|
||||
|
||||
namespace api.Features.Email.Services;
|
||||
namespace TrackQrApi.Features.Email.Services;
|
||||
|
||||
public class SmtpEmailService : IEmailService
|
||||
{
|
||||
private readonly EmailSettings _settings;
|
||||
private readonly ILogger<SmtpEmailService> _logger;
|
||||
private readonly EmailSettings _settings;
|
||||
|
||||
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
|
||||
{
|
||||
@@ -25,7 +25,8 @@ public class SmtpEmailService : IEmailService
|
||||
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
|
||||
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
|
||||
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
|
||||
@@ -43,7 +44,8 @@ public class SmtpEmailService : IEmailService
|
||||
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody, CancellationToken ct)
|
||||
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_settings.Smtp == null)
|
||||
{
|
||||
@@ -76,9 +78,7 @@ public class SmtpEmailService : IEmailService
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
|
||||
}
|
||||
|
||||
await client.SendMailAsync(message, ct);
|
||||
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Email.Templates;
|
||||
namespace TrackQrApi.Features.Email.Templates;
|
||||
|
||||
public static class EmailTemplates
|
||||
{
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using api.Data;
|
||||
using api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Events.Services;
|
||||
namespace TrackQrApi.Features.Events.Services;
|
||||
|
||||
public interface IEventTrackingService
|
||||
{
|
||||
@@ -13,7 +13,10 @@ public interface IEventTrackingService
|
||||
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
|
||||
}
|
||||
|
||||
public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpService geoIpService, ILogger<EventTrackingService> logger)
|
||||
public class EventTrackingService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IGeoIpService geoIpService,
|
||||
ILogger<EventTrackingService> logger)
|
||||
: IEventTrackingService
|
||||
{
|
||||
// Dedupe window - same visitor clicking same link within this window counts as one
|
||||
@@ -112,10 +115,8 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpServi
|
||||
// Check for forwarded headers (when behind a proxy/load balancer)
|
||||
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(forwardedFor))
|
||||
{
|
||||
// Take the first IP in the chain (client IP)
|
||||
return forwardedFor.Split(',')[0].Trim();
|
||||
}
|
||||
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Net;
|
||||
using MaxMind.GeoIP2;
|
||||
|
||||
namespace api.Features.Events.Services;
|
||||
namespace TrackQrApi.Features.Events.Services;
|
||||
|
||||
public interface IGeoIpService
|
||||
{
|
||||
@@ -10,8 +10,8 @@ public interface IGeoIpService
|
||||
|
||||
public class GeoIpService : IGeoIpService, IDisposable
|
||||
{
|
||||
private readonly DatabaseReader? _reader;
|
||||
private readonly ILogger<GeoIpService> _logger;
|
||||
private readonly DatabaseReader? _reader;
|
||||
|
||||
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
|
||||
{
|
||||
@@ -19,7 +19,6 @@ public class GeoIpService : IGeoIpService, IDisposable
|
||||
var dbPath = configuration["GeoIP:DatabasePath"];
|
||||
|
||||
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
_reader = new DatabaseReader(dbPath);
|
||||
@@ -29,11 +28,13 @@ public class GeoIpService : IGeoIpService, IDisposable
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_reader?.Dispose();
|
||||
}
|
||||
|
||||
public string? GetCountryCode(string ipAddress)
|
||||
@@ -44,20 +45,11 @@ public class GeoIpService : IGeoIpService, IDisposable
|
||||
try
|
||||
{
|
||||
// Handle localhost and private IPs
|
||||
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) return null;
|
||||
|
||||
if (!IPAddress.TryParse(ipAddress, out var ip))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!IPAddress.TryParse(ipAddress, out var ip)) return null;
|
||||
|
||||
if (_reader.TryCountry(ip, out var response))
|
||||
{
|
||||
return response?.Country?.IsoCode;
|
||||
}
|
||||
if (_reader.TryCountry(ip, out var response)) return response?.Country?.IsoCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -87,9 +79,4 @@ public class GeoIpService : IGeoIpService, IDisposable
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_reader?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Common;
|
||||
|
||||
public class LinkDto
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required Guid Id { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public required string DestinationUrl { get; set; }
|
||||
public required string? Title { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public int ClickCount { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Links.Common;
|
||||
namespace TrackQrApi.Features.Links.Common;
|
||||
|
||||
public record LinkResponse(
|
||||
Guid Id,
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace api.Features.Links.Common;
|
||||
namespace TrackQrApi.Features.Links.Common;
|
||||
|
||||
public static class SlugGenerator
|
||||
{
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class BulkCreateLinksRequest
|
||||
{
|
||||
@@ -59,7 +59,8 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
||||
// Limit bulk creation to 100 links at a time
|
||||
if (req.Links.Count > 100)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
||||
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
|
||||
var linkLimit = GetPlanLinkLimit(workspace.Plan);
|
||||
|
||||
for (int i = 0; i < req.Links.Count; i++)
|
||||
for (var i = 0; i < req.Links.Count; i++)
|
||||
{
|
||||
var item = req.Links[i];
|
||||
|
||||
@@ -130,7 +131,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
||||
Title = item.Title,
|
||||
Status = ShortLinkStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.ShortLinks.Add(link);
|
||||
@@ -143,7 +144,7 @@ public class BulkCreateLinksEndpoint(AppDbContext db)
|
||||
Title = link?.Title,
|
||||
Status = link.Status.ToString(),
|
||||
ClickCount = 0,
|
||||
CreatedAt = link.CreatedAt,
|
||||
CreatedAt = link.CreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class CreateLinkRequest
|
||||
{
|
||||
@@ -30,7 +30,8 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
|
||||
|
||||
RuleFor(x => x.Slug)
|
||||
.MaximumLength(50).WithMessage("Slug must not exceed 50 characters")
|
||||
.Matches(@"^[a-zA-Z0-9_-]*$").WithMessage("Slug can only contain letters, numbers, hyphens, and underscores")
|
||||
.Matches(@"^[a-zA-Z0-9_-]*$")
|
||||
.WithMessage("Slug can only contain letters, numbers, hyphens, and underscores")
|
||||
.When(x => !string.IsNullOrEmpty(x.Slug));
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
@@ -107,7 +108,8 @@ public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
|
||||
if (slugExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class DeleteLinkRequest
|
||||
{
|
||||
@@ -42,6 +42,6 @@ public class DeleteLinkEndpoint(AppDbContext db)
|
||||
link.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class GetLinkRequest
|
||||
{
|
||||
@@ -26,7 +26,8 @@ public class GetLinkEndpoint(AppDbContext db)
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId && l.DeletedAt == null)
|
||||
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId &&
|
||||
l.DeletedAt == null)
|
||||
.Select(l => new LinkResponse(
|
||||
l.Id,
|
||||
l.WorkspaceId,
|
||||
@@ -50,6 +51,6 @@ public class GetLinkEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(link, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(link, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class ListLinksRequest
|
||||
{
|
||||
@@ -41,22 +42,14 @@ public class ListLinksEndpoint(AppDbContext db)
|
||||
.Where(l => l.WorkspaceId == req.WorkspaceId);
|
||||
|
||||
// Filter by deleted status (exclude soft-deleted by default)
|
||||
if (!req.IncludeDeleted)
|
||||
{
|
||||
query = query.Where(l => l.DeletedAt == null);
|
||||
}
|
||||
if (!req.IncludeDeleted) query = query.Where(l => l.DeletedAt == null);
|
||||
|
||||
// Filter by project if specified
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
query = query.Where(l => l.ProjectId == req.ProjectId.Value);
|
||||
}
|
||||
if (req.ProjectId.HasValue) query = query.Where(l => l.ProjectId == req.ProjectId.Value);
|
||||
|
||||
// Filter by status if specified
|
||||
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<Models.ShortLinkStatus>(req.Status, true, out var status))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
|
||||
query = query.Where(l => l.Status == status);
|
||||
}
|
||||
|
||||
var links = await query
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
@@ -77,6 +70,6 @@ public class ListLinksEndpoint(AppDbContext db)
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new LinkListResponse(links), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new LinkListResponse(links), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class RestoreLinkRequest
|
||||
{
|
||||
@@ -60,6 +60,6 @@ public class RestoreLinkEndpoint(AppDbContext db)
|
||||
link.DeletedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Links.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
namespace TrackQrApi.Features.Links.Endpoints;
|
||||
|
||||
public class UpdateLinkRequest
|
||||
{
|
||||
@@ -66,7 +66,8 @@ public class UpdateLinkEndpoint(AppDbContext db)
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Include(l => l.Workspace)
|
||||
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
@@ -88,43 +89,22 @@ public class UpdateLinkEndpoint(AppDbContext db)
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (!string.IsNullOrEmpty(req.DestinationUrl))
|
||||
{
|
||||
link.DestinationUrl = req.DestinationUrl;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(req.DestinationUrl)) link.DestinationUrl = req.DestinationUrl;
|
||||
|
||||
if (req.Title != null)
|
||||
{
|
||||
link.Title = req.Title;
|
||||
}
|
||||
if (req.Title != null) link.Title = req.Title;
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
|
||||
{
|
||||
link.Status = status;
|
||||
}
|
||||
|
||||
if (req.ExpiresAt.HasValue)
|
||||
{
|
||||
link.ExpiresAt = req.ExpiresAt.Value;
|
||||
}
|
||||
if (req.ExpiresAt.HasValue) link.ExpiresAt = req.ExpiresAt.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Password))
|
||||
{
|
||||
link.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password);
|
||||
}
|
||||
else if (req.RemovePassword == true)
|
||||
{
|
||||
link.PasswordHash = null;
|
||||
}
|
||||
else if (req.RemovePassword == true) link.PasswordHash = null;
|
||||
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
link.ProjectId = req.ProjectId.Value;
|
||||
}
|
||||
else if (req.RemoveProject == true)
|
||||
{
|
||||
link.ProjectId = null;
|
||||
}
|
||||
else if (req.RemoveProject == true) link.ProjectId = null;
|
||||
|
||||
link.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -144,6 +124,6 @@ public class UpdateLinkEndpoint(AppDbContext db)
|
||||
link.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using api.Data;
|
||||
using api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Plans.Services;
|
||||
namespace TrackQrApi.Features.Plans.Services;
|
||||
|
||||
public interface IPlanLimitsService
|
||||
{
|
||||
@@ -51,38 +51,41 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
|
||||
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
|
||||
{
|
||||
[WorkspacePlan.Free] = new PlanLimits(
|
||||
MaxWorkspaces: 1,
|
||||
MaxLinksPerWorkspace: 50,
|
||||
MaxQRCodesPerWorkspace: 25,
|
||||
MaxDomainsPerWorkspace: 0,
|
||||
MaxEventsPerMonth: 10_000,
|
||||
HasCustomDomains: false,
|
||||
HasPasswordProtection: false,
|
||||
HasAnalytics: true
|
||||
1,
|
||||
50,
|
||||
25,
|
||||
0,
|
||||
10_000,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
),
|
||||
[WorkspacePlan.Pro] = new PlanLimits(
|
||||
MaxWorkspaces: 5,
|
||||
MaxLinksPerWorkspace: 5_000,
|
||||
MaxQRCodesPerWorkspace: 1_000,
|
||||
MaxDomainsPerWorkspace: 3,
|
||||
MaxEventsPerMonth: 100_000,
|
||||
HasCustomDomains: true,
|
||||
HasPasswordProtection: true,
|
||||
HasAnalytics: true
|
||||
5,
|
||||
5_000,
|
||||
1_000,
|
||||
3,
|
||||
100_000,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
),
|
||||
[WorkspacePlan.Business] = new PlanLimits(
|
||||
MaxWorkspaces: int.MaxValue,
|
||||
MaxLinksPerWorkspace: int.MaxValue,
|
||||
MaxQRCodesPerWorkspace: int.MaxValue,
|
||||
MaxDomainsPerWorkspace: int.MaxValue,
|
||||
MaxEventsPerMonth: int.MaxValue,
|
||||
HasCustomDomains: true,
|
||||
HasPasswordProtection: true,
|
||||
HasAnalytics: true
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
int.MaxValue,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
)
|
||||
};
|
||||
|
||||
public PlanLimits GetLimits(WorkspacePlan plan) => PlanConfigs[plan];
|
||||
public PlanLimits GetLimits(WorkspacePlan plan)
|
||||
{
|
||||
return PlanConfigs[plan];
|
||||
}
|
||||
|
||||
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -114,12 +117,12 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
|
||||
: WorkspacePlan.Free;
|
||||
|
||||
return new UsageStats(
|
||||
TotalWorkspaces: workspaces.Count,
|
||||
TotalLinks: totalLinks,
|
||||
TotalQRCodes: totalQRCodes,
|
||||
TotalDomains: totalDomains,
|
||||
EventsThisMonth: eventsThisMonth,
|
||||
HighestPlan: highestPlan
|
||||
workspaces.Count,
|
||||
totalLinks,
|
||||
totalQRCodes,
|
||||
totalDomains,
|
||||
eventsThisMonth,
|
||||
highestPlan
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,13 +150,13 @@ public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsS
|
||||
var limits = GetLimits(workspace.Plan);
|
||||
|
||||
return new WorkspaceUsageStats(
|
||||
WorkspaceId: workspaceId,
|
||||
Plan: workspace.Plan,
|
||||
Links: links,
|
||||
QRCodes: qrCodes,
|
||||
Domains: domains,
|
||||
EventsThisMonth: eventsThisMonth,
|
||||
Limits: limits
|
||||
workspaceId,
|
||||
workspace.Plan,
|
||||
links,
|
||||
qrCodes,
|
||||
domains,
|
||||
eventsThisMonth,
|
||||
limits
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Projects.Common;
|
||||
namespace TrackQrApi.Features.Projects.Common;
|
||||
|
||||
public record ProjectResponse(
|
||||
Guid Id,
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Projects.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Projects.Endpoints;
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class CreateProjectRequest
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Projects.Endpoints;
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class DeleteProjectRequest
|
||||
{
|
||||
@@ -26,7 +26,8 @@ public class DeleteProjectEndpoint(AppDbContext db)
|
||||
|
||||
var project = await db.Projects
|
||||
.Include(p => p.Workspace)
|
||||
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (project is null)
|
||||
{
|
||||
@@ -37,6 +38,6 @@ public class DeleteProjectEndpoint(AppDbContext db)
|
||||
db.Projects.Remove(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Projects.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
|
||||
namespace api.Features.Projects.Endpoints;
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class GetProjectRequest
|
||||
{
|
||||
@@ -44,6 +44,6 @@ public class GetProjectEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(project, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(project, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Projects.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
|
||||
namespace api.Features.Projects.Endpoints;
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class ListProjectsRequest
|
||||
{
|
||||
@@ -48,6 +48,6 @@ public class ListProjectsEndpoint(AppDbContext db)
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new ProjectListResponse(projects), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Projects.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Projects.Common;
|
||||
|
||||
namespace api.Features.Projects.Endpoints;
|
||||
namespace TrackQrApi.Features.Projects.Endpoints;
|
||||
|
||||
public class UpdateProjectRequest
|
||||
{
|
||||
@@ -42,7 +42,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
|
||||
.Include(p => p.Workspace)
|
||||
.Include(p => p.ShortLinks)
|
||||
.Include(p => p.QRCodeDesigns)
|
||||
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (project is null)
|
||||
{
|
||||
@@ -64,6 +65,6 @@ public class UpdateProjectEndpoint(AppDbContext db)
|
||||
project.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace api.Features.QRCodes.Common;
|
||||
namespace TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
/// <summary>
|
||||
/// QR code style configuration stored as JSON
|
||||
/// QR code style configuration stored as JSON
|
||||
/// </summary>
|
||||
public class QRCodeStyle
|
||||
{
|
||||
@@ -1,15 +1,15 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Features.QRCodes.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class CreateQRCodeRequest
|
||||
{
|
||||
@@ -73,9 +73,11 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
linkSlug = link.Slug;
|
||||
}
|
||||
|
||||
@@ -103,7 +105,8 @@ public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class DeleteQRCodeRequest
|
||||
{
|
||||
@@ -26,7 +26,8 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.Workspace)
|
||||
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (qrCode is null)
|
||||
{
|
||||
@@ -37,6 +38,6 @@ public class DeleteQRCodeEndpoint(AppDbContext db)
|
||||
db.QrCodeDesigns.Remove(qrCode);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using api.Features.QRCodes.Services;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
using TrackQrApi.Features.QRCodes.Services;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class ExportQRCodeRequest
|
||||
{
|
||||
@@ -18,7 +18,10 @@ public class ExportQRCodeRequest
|
||||
public int? Size { get; set; }
|
||||
}
|
||||
|
||||
public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
|
||||
public class ExportQRCodeEndpoint(
|
||||
AppDbContext db,
|
||||
IQrCodeGeneratorService qrGenerator,
|
||||
IAssetStorageService assetStorage)
|
||||
: Endpoint<ExportQRCodeRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -44,7 +47,8 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
|
||||
|
||||
if (qrCode.ShortLink is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,10 +67,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGen
|
||||
if (qrCode.LogoAsset != null)
|
||||
{
|
||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||
if (logoResult.HasValue)
|
||||
{
|
||||
logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class GetQRCodeAnalyticsRequest
|
||||
{
|
||||
@@ -65,16 +65,16 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var summary = new QRCodeAnalyticsSummary(
|
||||
TotalScans: events.Count,
|
||||
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count()
|
||||
events.Count,
|
||||
events.Select(e => e.IpHash).Distinct().Count()
|
||||
);
|
||||
|
||||
var timeSeries = events
|
||||
.GroupBy(e => e.Timestamp.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new QRCodeTimeSeriesPoint(
|
||||
Date: g.Key.ToString("yyyy-MM-dd"),
|
||||
Scans: g.Count()
|
||||
g.Key.ToString("yyyy-MM-dd"),
|
||||
g.Count()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
@@ -98,14 +98,14 @@ public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var response = new QRCodeAnalyticsResponse(
|
||||
QRCodeId: qrCode.Id,
|
||||
Name: qrCode.Name,
|
||||
LinkSlug: qrCode.ShortLink?.Slug,
|
||||
Summary: summary,
|
||||
TimeSeries: timeSeries,
|
||||
DeviceBreakdown: deviceBreakdown,
|
||||
ReferrerBreakdown: referrerBreakdown,
|
||||
CountryBreakdown: countryBreakdown
|
||||
qrCode.Id,
|
||||
qrCode.Name,
|
||||
qrCode.ShortLink?.Slug,
|
||||
summary,
|
||||
timeSeries,
|
||||
deviceBreakdown,
|
||||
referrerBreakdown,
|
||||
countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class GetQRCodeRequest
|
||||
{
|
||||
@@ -55,6 +55,6 @@ public class GetQRCodeEndpoint(AppDbContext db)
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class ListQRCodesRequest
|
||||
{
|
||||
@@ -40,15 +40,9 @@ public class ListQRCodesEndpoint(AppDbContext db)
|
||||
var query = db.QrCodeDesigns
|
||||
.Where(q => q.WorkspaceId == req.WorkspaceId);
|
||||
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
query = query.Where(q => q.ProjectId == req.ProjectId.Value);
|
||||
}
|
||||
if (req.ProjectId.HasValue) query = query.Where(q => q.ProjectId == req.ProjectId.Value);
|
||||
|
||||
if (req.ShortLinkId.HasValue)
|
||||
{
|
||||
query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
|
||||
}
|
||||
if (req.ShortLinkId.HasValue) query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
|
||||
|
||||
var qrCodes = await query
|
||||
.Include(q => q.ShortLink)
|
||||
@@ -74,6 +68,6 @@ public class ListQRCodesEndpoint(AppDbContext db)
|
||||
))
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using api.Features.QRCodes.Services;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Assets.Services;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
using TrackQrApi.Features.QRCodes.Services;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class PreviewQRCodeRequest
|
||||
{
|
||||
@@ -17,7 +17,10 @@ public class PreviewQRCodeRequest
|
||||
public int? Size { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
|
||||
public class PreviewQRCodeEndpoint(
|
||||
AppDbContext db,
|
||||
IQrCodeGeneratorService qrGenerator,
|
||||
IAssetStorageService assetStorage)
|
||||
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -43,7 +46,8 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
|
||||
|
||||
if (qrCode.ShortLink is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,10 +64,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
|
||||
if (qrCode.LogoAsset != null)
|
||||
{
|
||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||
if (logoResult.HasValue)
|
||||
{
|
||||
logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
if (logoResult.HasValue) logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -71,13 +72,13 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGe
|
||||
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
|
||||
|
||||
var response = new QRCodePreviewResponse(
|
||||
DataUrl: dataUrl,
|
||||
Format: "png",
|
||||
Width: size,
|
||||
Height: size
|
||||
dataUrl,
|
||||
"png",
|
||||
size,
|
||||
size
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
namespace TrackQrApi.Features.QRCodes.Endpoints;
|
||||
|
||||
public class UpdateQRCodeRequest
|
||||
{
|
||||
@@ -36,7 +36,8 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
.Include(q => q.Workspace)
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(
|
||||
q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (qrCode is null)
|
||||
{
|
||||
@@ -55,6 +56,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
qrCode.ProjectId = req.ProjectId.Value;
|
||||
}
|
||||
else if (req.RemoveProject == true)
|
||||
@@ -63,10 +65,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
}
|
||||
|
||||
// Update name if provided
|
||||
if (!string.IsNullOrWhiteSpace(req.Name))
|
||||
{
|
||||
qrCode.Name = req.Name;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(req.Name)) qrCode.Name = req.Name;
|
||||
|
||||
// Handle logo asset update
|
||||
if (req.LogoAssetId.HasValue)
|
||||
@@ -76,9 +75,11 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
|
||||
if (!assetExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
qrCode.LogoAssetId = req.LogoAssetId.Value;
|
||||
// Reload the asset for the response
|
||||
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
|
||||
@@ -89,10 +90,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
qrCode.LogoAsset = null;
|
||||
}
|
||||
|
||||
if (req.Style != null)
|
||||
{
|
||||
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
|
||||
}
|
||||
if (req.Style != null) qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
|
||||
|
||||
qrCode.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -114,6 +112,6 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
using api.Features.QRCodes.Common;
|
||||
using System.Text;
|
||||
using QRCoder;
|
||||
using SkiaSharp;
|
||||
using TrackQrApi.Features.QRCodes.Common;
|
||||
|
||||
namespace api.Features.QRCodes.Services;
|
||||
namespace TrackQrApi.Features.QRCodes.Services;
|
||||
|
||||
public interface IQrCodeGeneratorService
|
||||
{
|
||||
@@ -23,7 +24,7 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
||||
var moduleCount = moduleMatrix.Count;
|
||||
|
||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||
var totalModules = moduleCount + (style.QuietZone * 2);
|
||||
var totalModules = moduleCount + style.QuietZone * 2;
|
||||
var pixelsPerModule = Math.Max(4, size / totalModules);
|
||||
var actualSize = totalModules * pixelsPerModule;
|
||||
|
||||
@@ -47,29 +48,21 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
||||
|
||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||
|
||||
for (int y = 0; y < moduleCount; y++)
|
||||
{
|
||||
for (int x = 0; x < moduleCount; x++)
|
||||
for (var y = 0; y < moduleCount; y++)
|
||||
for (var x = 0; x < moduleCount; x++)
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
var px = quietZoneOffset + (x * pixelsPerModule);
|
||||
var py = quietZoneOffset + (y * pixelsPerModule);
|
||||
var px = quietZoneOffset + x * pixelsPerModule;
|
||||
var py = quietZoneOffset + y * pixelsPerModule;
|
||||
|
||||
// Check if this is part of a finder pattern (eyes)
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
// Check if this is part of a finder pattern (eyes)
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
|
||||
if (isEye)
|
||||
{
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
|
||||
}
|
||||
}
|
||||
if (isEye)
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
|
||||
else
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
@@ -77,15 +70,84 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
||||
var qrBytes = data.ToArray();
|
||||
|
||||
// If no logo, return the QR code as-is
|
||||
if (logoStream == null)
|
||||
{
|
||||
return qrBytes;
|
||||
}
|
||||
if (logoStream == null) return qrBytes;
|
||||
|
||||
// Overlay logo on QR code
|
||||
return OverlayLogo(qrBytes, logoStream, actualSize);
|
||||
}
|
||||
|
||||
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
|
||||
{
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
||||
|
||||
var moduleMatrix = qrCodeData.ModuleMatrix;
|
||||
var moduleCount = moduleMatrix.Count;
|
||||
|
||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||
var totalModules = moduleCount + style.QuietZone * 2;
|
||||
var pixelsPerModule = (float)size / totalModules;
|
||||
var actualSize = size;
|
||||
|
||||
var foreground = style.ForegroundColor;
|
||||
var background = style.BackgroundColor;
|
||||
|
||||
var svg = new StringBuilder();
|
||||
svg.AppendLine(
|
||||
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
|
||||
svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
|
||||
|
||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||
|
||||
for (var y = 0; y < moduleCount; y++)
|
||||
for (var x = 0; x < moduleCount; x++)
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
var px = quietZoneOffset + x * pixelsPerModule;
|
||||
var py = quietZoneOffset + y * pixelsPerModule;
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
var shape = isEye ? style.EyeShape : style.ModuleShape;
|
||||
|
||||
var padding = pixelsPerModule * 0.1f;
|
||||
var moduleSize = pixelsPerModule - padding;
|
||||
|
||||
switch (shape.ToLowerInvariant())
|
||||
{
|
||||
case "circle":
|
||||
case "dots":
|
||||
var radius = moduleSize / 2;
|
||||
var cx = px + pixelsPerModule / 2;
|
||||
var cy = py + pixelsPerModule / 2;
|
||||
svg.AppendLine(
|
||||
$" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "rounded":
|
||||
var cornerRadius = moduleSize * 0.3f;
|
||||
svg.AppendLine(
|
||||
$" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "square":
|
||||
default:
|
||||
svg.AppendLine(
|
||||
$" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
svg.AppendLine("</svg>");
|
||||
return svg.ToString();
|
||||
}
|
||||
|
||||
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
|
||||
{
|
||||
var pngBytes = GeneratePng(content, style, size, logoStream);
|
||||
var base64 = Convert.ToBase64String(pngBytes);
|
||||
return $"data:image/png;base64,{base64}";
|
||||
}
|
||||
|
||||
private static bool IsFinderPattern(int x, int y, int moduleCount)
|
||||
{
|
||||
// Top-left finder pattern: 0-6, 0-6
|
||||
@@ -126,87 +188,12 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
||||
}
|
||||
}
|
||||
|
||||
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
|
||||
{
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
||||
|
||||
var moduleMatrix = qrCodeData.ModuleMatrix;
|
||||
var moduleCount = moduleMatrix.Count;
|
||||
|
||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||
var totalModules = moduleCount + (style.QuietZone * 2);
|
||||
var pixelsPerModule = (float)size / totalModules;
|
||||
var actualSize = size;
|
||||
|
||||
var foreground = style.ForegroundColor;
|
||||
var background = style.BackgroundColor;
|
||||
|
||||
var svg = new System.Text.StringBuilder();
|
||||
svg.AppendLine($"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
|
||||
svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
|
||||
|
||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||
|
||||
for (int y = 0; y < moduleCount; y++)
|
||||
{
|
||||
for (int x = 0; x < moduleCount; x++)
|
||||
{
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
var px = quietZoneOffset + (x * pixelsPerModule);
|
||||
var py = quietZoneOffset + (y * pixelsPerModule);
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
var shape = isEye ? style.EyeShape : style.ModuleShape;
|
||||
|
||||
var padding = pixelsPerModule * 0.1f;
|
||||
var moduleSize = pixelsPerModule - padding;
|
||||
|
||||
switch (shape.ToLowerInvariant())
|
||||
{
|
||||
case "circle":
|
||||
case "dots":
|
||||
var radius = moduleSize / 2;
|
||||
var cx = px + pixelsPerModule / 2;
|
||||
var cy = py + pixelsPerModule / 2;
|
||||
svg.AppendLine($" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "rounded":
|
||||
var cornerRadius = moduleSize * 0.3f;
|
||||
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "square":
|
||||
default:
|
||||
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg.AppendLine("</svg>");
|
||||
return svg.ToString();
|
||||
}
|
||||
|
||||
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
|
||||
{
|
||||
var pngBytes = GeneratePng(content, style, size, logoStream);
|
||||
var base64 = Convert.ToBase64String(pngBytes);
|
||||
return $"data:image/png;base64,{base64}";
|
||||
}
|
||||
|
||||
private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
|
||||
{
|
||||
using var qrBitmap = SKBitmap.Decode(qrBytes);
|
||||
using var logoBitmap = SKBitmap.Decode(logoStream);
|
||||
|
||||
if (qrBitmap == null || logoBitmap == null)
|
||||
{
|
||||
return qrBytes;
|
||||
}
|
||||
if (qrBitmap == null || logoBitmap == null) return qrBytes;
|
||||
|
||||
// Logo should be about 20% of QR code size
|
||||
var logoSize = (int)(qrSize * 0.2);
|
||||
@@ -234,10 +221,7 @@ public class QrCodeGeneratorService : IQrCodeGeneratorService
|
||||
using var resizedLogo = logoBitmap.Resize(
|
||||
new SKImageInfo(logoSize, logoSize),
|
||||
new SKSamplingOptions(SKCubicResampler.Mitchell));
|
||||
if (resizedLogo != null)
|
||||
{
|
||||
canvas.DrawBitmap(resizedLogo, logoX, logoY);
|
||||
}
|
||||
if (resizedLogo != null) canvas.DrawBitmap(resizedLogo, logoX, logoY);
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
@@ -1,12 +1,12 @@
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Events.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Events.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Redirect.Endpoints;
|
||||
namespace TrackQrApi.Features.Redirect.Endpoints;
|
||||
|
||||
public class PasswordRedirectRequest
|
||||
{
|
||||
@@ -1,11 +1,11 @@
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Events.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Events.Services;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Redirect.Endpoints;
|
||||
namespace TrackQrApi.Features.Redirect.Endpoints;
|
||||
|
||||
public class RedirectRequest
|
||||
{
|
||||
@@ -78,13 +78,9 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
|
||||
// Track event asynchronously (fire and forget)
|
||||
// If qr parameter is present, track as scan; otherwise track as click
|
||||
if (req.Qr.HasValue)
|
||||
{
|
||||
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
|
||||
}
|
||||
else
|
||||
{
|
||||
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
||||
}
|
||||
|
||||
// Redirect to destination (302 Found)
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace api.Features.Workspaces.Common;
|
||||
namespace TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
public record WorkspaceResponse(
|
||||
Guid Id,
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Features.Workspaces.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Plans.Services;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
using TrackQrApi.Models;
|
||||
|
||||
namespace api.Features.Workspaces.Endpoints;
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class CreateWorkspaceRequest
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
|
||||
namespace api.Features.Workspaces.Endpoints;
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class DeleteWorkspaceRequest
|
||||
{
|
||||
@@ -35,6 +35,6 @@ public class DeleteWorkspaceEndpoint(AppDbContext db)
|
||||
db.Workspaces.Remove(workspace);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Workspaces.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrackQrApi.Data;
|
||||
using TrackQrApi.Features.Auth.Common;
|
||||
using TrackQrApi.Features.Workspaces.Common;
|
||||
|
||||
namespace api.Features.Workspaces.Endpoints;
|
||||
namespace TrackQrApi.Features.Workspaces.Endpoints;
|
||||
|
||||
public class GetWorkspaceRequest
|
||||
{
|
||||
@@ -40,6 +40,6 @@ public class GetWorkspaceEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(workspace, 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(workspace, cancellation: ct);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user