diff --git a/docs/tasks.md b/docs/tasks.md index f881459..b249a69 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -175,13 +175,13 @@ --- -## Phase 6: Frontend Dashboard (In Progress) +## Phase 6: Frontend Dashboard (Complete) ### Authentication UI - [x] Login page - [x] Registration page -- [ ] Forgot password page -- [ ] Password reset page +- [x] Forgot password page +- [x] Password reset page - [x] Auth state management (Pinia store) ### Dashboard @@ -198,17 +198,17 @@ ### QR Designer UI - [x] QR designer page - [x] Color pickers -- [~] Shape selectors (basic support) -- [ ] Logo upload integration +- [x] Shape selectors (Square, Rounded, Dots for modules; Square, Rounded, Circle for eyes) +- [x] Logo upload integration (upload new or select from existing assets) - [x] Live preview (for saved QR codes) - [x] Export buttons (PNG/SVG) -- [x] Style presets (6 presets) +- [x] Style presets (6 presets with shape variations) ### Analytics UI - [x] Charts (time series with clicks/scans) - [x] Stat cards (clicks, scans, visitors, total) - [x] Breakdown tables (referrer, device) -- [~] Geo breakdown (API ready, UI pending) +- [x] Geo breakdown (country flags and names, requires MaxMind GeoIP2 database) --- @@ -230,8 +230,7 @@ ### Plan & Quotas - [ ] Usage tracking - [ ] Plan limits enforcement - - Free: 50 links, 1 workspace - - Pro: 5,000 links, 5 workspaces + - Free: 50 links, 1 workspacf - Pro: 5,000 links, 5 workspaces - Business: Unlimited - [ ] Upgrade prompts @@ -297,6 +296,131 @@ Completed: --- +## Gap Analysis (Spec vs Implementation) + +> This section identifies gaps between the MVP spec (`docs/spec.md`) and the current implementation. + +### Authentication & Account + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Email verification | ❌ Missing | Endpoint structure exists, but no email sending or verification flow | +| Basic account settings page | ❌ Missing | No settings UI or endpoints for profile updates | +| SSO (optional, post-MVP) | ⏳ Deferred | As expected | + +### Short Link Features + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| UTM builder (preset templates) | ❌ Missing | Spec mentions UTM builder for Pro plan | +| Destination URL allowlist/denylist | ❌ Missing | Abuse prevention not implemented | +| Soft delete for links | ❌ Missing | Currently using hard delete | + +### QR Code Designer + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Shape presets (module shapes) | ✅ Complete | Square, Rounded, Dots module shapes supported | +| Eye shape customization | ✅ Complete | Square, Rounded, Circle eye shapes supported | +| Logo upload integration | ✅ Complete | Upload new or select from existing assets | +| Logo size + margin controls | ⚠️ Partial | Fixed 20% size, no user controls | +| Print-ready options ("high contrast" toggle) | ❌ Missing | No print optimization features | + +### Analytics & Tracking + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Geo (country) breakdown | ✅ Complete | MaxMind GeoIP2 integrated, UI with country flags | +| Per-QR analytics endpoint | ❌ Missing | Spec: `GET /analytics/qrcode/{id}` - only link analytics exist | +| Scan vs Click distinction via `?qr=` param | ⚠️ Partial | Event type exists but QR export doesn't append `?qr=` to URLs | +| Custom date range filter | ❌ Missing | Only 24h/7d/30d implemented, spec mentions custom range | +| Monthly IP salt rotation | ❌ Missing | Spec requires rotating salt for privacy compliance | +| Event retention configuration per plan | ❌ Missing | No retention policy or cleanup jobs | + +### Admin & Quotas + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Subscription status display | ❌ Missing | Plan field exists on Workspace but no UI | +| Usage quotas enforcement | ❌ Missing | No limits enforced for links/QRs/events/domains | +| Upgrade prompts | ❌ Missing | No paywall or upgrade flows | + +### Security & Non-Functional + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Rate limiting on public endpoints | ❌ Missing | Critical for redirect endpoint | +| CORS configuration | ❌ Missing | Needs proper configuration | +| Strict CSP headers | ❌ Missing | App pages have no CSP | +| Request logging | ❌ Missing | No structured logging | +| Error handling middleware | ❌ Missing | No global error handler | + +### Frontend UI Pages + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Forgot password page | ✅ Complete | Full UI with success state | +| Password reset page | ✅ Complete | Full UI with token validation and success state | +| Projects list UI | ❌ Missing | Backend CRUD complete, no frontend | +| Domains page (add/verify) | ❌ Missing | Backend complete, no frontend | +| Workspace switcher (full UI) | ⚠️ Partial | Basic switcher exists, no create/manage UI | +| Per-QR analytics view | ❌ Missing | Only per-link analytics in UI | + +### Email System + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Email service integration | ❌ Missing | No email provider configured | +| Email verification emails | ❌ Missing | No templates or sending logic | +| Password reset emails | ❌ Missing | Token generated but not emailed | +| Email templates | ❌ Missing | No templating system | + +### Background Jobs + +| Spec Requirement | Status | Notes | +|-----------------|--------|-------| +| Domain verification checks | ❌ Missing | Only manual verification, no periodic checks | +| Event enrichment (geo/device) | ✅ Complete | Device parsing and GeoIP country lookup done | +| Cleanup & retention tasks | ❌ Missing | No scheduled cleanup for old events | + +### API Surface Gaps + +| Endpoint (from spec) | Status | +|---------------------|--------| +| `GET /analytics/qrcode/{id}` | ❌ Missing | +| Account settings endpoints | ❌ Missing | +| Usage/quota endpoints | ❌ Missing | + +--- + +## Priority Gap Resolution + +### High Priority (MVP Blockers) + +1. **Email system** - Verification and password reset cannot work without email +2. **Rate limiting** - Security risk without it on public redirect +3. **QR scan tracking** - QR exports need `?qr=` param for scan attribution +4. ~~**Geo breakdown** - GeoIP integration for country-level analytics~~ ✅ Complete +5. **Projects UI** - Backend exists, needs frontend + +### Medium Priority (MVP Polish) + +6. **Account settings page** - Users need to update profile +7. **Domains UI** - Backend exists, needs frontend +8. **Usage quotas** - Enforce plan limits +9. ~~**QR shape presets** - More customization options~~ ✅ Complete (Square, Rounded, Dots) +10. **Custom date range** - Analytics flexibility + +### Lower Priority (Post-MVP) + +11. UTM builder +12. Soft delete for links +13. Print-ready QR options +14. SSO integration +15. Stripe payments + +--- + ## Notes - Backend uses FastEndpoints (not traditional MVC controllers) diff --git a/src/api.Tests/ApiWebApplicationFactory.cs b/src/api.Tests/ApiWebApplicationFactory.cs index f0ecd61..06d676b 100644 --- a/src/api.Tests/ApiWebApplicationFactory.cs +++ b/src/api.Tests/ApiWebApplicationFactory.cs @@ -3,6 +3,7 @@ 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; @@ -13,8 +14,36 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory, I private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest") .Build(); + private bool _containerStarted = false; + + private void EnsureContainerStarted() + { + if (!_containerStarted) + { + _postgres.StartAsync().GetAwaiter().GetResult(); + _containerStarted = true; + } + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { + // Ensure container is started before we need the connection string + EnsureContainerStarted(); + + builder.UseEnvironment("Testing"); + + // Set environment variables for configuration (these take precedence) + Environment.SetEnvironmentVariable("Jwt__Secret", "test-secret-key-min-32-characters-long-for-hmac256!"); + Environment.SetEnvironmentVariable("Jwt__Issuer", "TrakQR"); + Environment.SetEnvironmentVariable("Jwt__Audience", "TrakQR"); + Environment.SetEnvironmentVariable("Jwt__ExpirationMinutes", "60"); + Environment.SetEnvironmentVariable("Email__Provider", "console"); + Environment.SetEnvironmentVariable("Stripe__SecretKey", "sk_test_fake_key"); + Environment.SetEnvironmentVariable("Stripe__WebhookSecret", "whsec_fake_secret"); + Environment.SetEnvironmentVariable("Stripe__ProPriceId", "price_test_pro"); + Environment.SetEnvironmentVariable("Stripe__BusinessPriceId", "price_test_business"); + Environment.SetEnvironmentVariable("ConnectionStrings__PostgresConnection", _postgres.GetConnectionString()); + builder.ConfigureTestServices(services => { // Remove existing DbContext registration @@ -29,12 +58,14 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory, I // Add DbContext with Testcontainers connection string services.AddDbContext(options => options.UseNpgsql(_postgres.GetConnectionString())); + }); } public async Task InitializeAsync() { - await _postgres.StartAsync(); + // Ensure container is started (might already be started from ConfigureWebHost) + EnsureContainerStarted(); // Run migrations using var scope = Services.CreateScope(); @@ -47,4 +78,19 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory, I await _postgres.DisposeAsync(); await base.DisposeAsync(); } + + /// + /// Upgrades a workspace to Pro plan for testing features that require paid plans. + /// + public async Task UpgradeWorkspaceToPro(Guid workspaceId) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var workspace = await db.Workspaces.FindAsync(workspaceId); + if (workspace != null) + { + workspace.Plan = api.Models.WorkspacePlan.Pro; + await db.SaveChangesAsync(); + } + } } diff --git a/src/api.Tests/AuthControllerTests.cs b/src/api.Tests/AuthControllerTests.cs index c439360..20d0e97 100644 --- a/src/api.Tests/AuthControllerTests.cs +++ b/src/api.Tests/AuthControllerTests.cs @@ -168,7 +168,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory) } [Fact] - public async Task ResetPassword_ReturnsNotImplemented() + public async Task ResetPassword_WithInvalidToken_ReturnsBadRequest() { // Arrange var request = new { Token = "some-token", NewPassword = "newpassword123" }; @@ -181,7 +181,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory) var result = await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Message.Should().Be("Password reset is not yet available"); + result!.Message.Should().Be("Invalid or expired reset token"); } [Fact] diff --git a/src/api.Tests/DomainEndpointTests.cs b/src/api.Tests/DomainEndpointTests.cs index 4632c2d..78f74e2 100644 --- a/src/api.Tests/DomainEndpointTests.cs +++ b/src/api.Tests/DomainEndpointTests.cs @@ -13,7 +13,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory) { private readonly HttpClient _client = factory.CreateClient(); - private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email) + 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) @@ -28,6 +28,12 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory) var workspaces = await workspacesResponse.Content.ReadFromJsonAsync(); 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); + } + return (token, workspaceId); } diff --git a/src/api.Tests/WorkspaceEndpointTests.cs b/src/api.Tests/WorkspaceEndpointTests.cs index 2704b37..feaa4a2 100644 --- a/src/api.Tests/WorkspaceEndpointTests.cs +++ b/src/api.Tests/WorkspaceEndpointTests.cs @@ -12,7 +12,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory) { private readonly HttpClient _client = factory.CreateClient(); - private async Task GetAuthTokenAsync(string email = "workspace-test@example.com") + 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) @@ -20,7 +20,25 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory) response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" }); } var result = await response.Content.ReadFromJsonAsync(); - return result!.Token; + var token = result!.Token; + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var workspacesResponse = await _client.GetAsync("/workspaces"); + var workspaces = await workspacesResponse.Content.ReadFromJsonAsync(); + var workspaceId = workspaces!.Workspaces.First().Id; + + if (upgradeToPro) + { + await factory.UpgradeWorkspaceToPro(workspaceId); + } + + return (token, workspaceId); + } + + private async Task GetAuthTokenAsync(string email = "workspace-test@example.com") + { + var (token, _) = await GetAuthAndWorkspaceAsync(email); + return token; } [Fact] @@ -53,8 +71,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory) [Fact] public async Task CreateWorkspace_WithValidData_ReturnsCreated() { - // Arrange - var token = await GetAuthTokenAsync("create-ws@example.com"); + // Arrange - upgrade to Pro to allow creating additional workspaces + var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", upgradeToPro: true); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act @@ -86,21 +104,18 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory) [Fact] public async Task GetWorkspace_WithValidId_ReturnsWorkspace() { - // Arrange - var token = await GetAuthTokenAsync("get-ws@example.com"); + // Arrange - use the default workspace (created on registration) + var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-ws@example.com"); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Get Test" }); - var created = await createResponse.Content.ReadFromJsonAsync(); - // Act - var response = await _client.GetAsync($"/workspaces/{created!.Id}"); + var response = await _client.GetAsync($"/workspaces/{workspaceId}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content.ReadFromJsonAsync(); - result!.Id.Should().Be(created.Id); - result.Name.Should().Be("Get Test"); + result!.Id.Should().Be(workspaceId); + result.Name.Should().NotBeNullOrEmpty(); } [Fact] @@ -120,15 +135,12 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory) [Fact] public async Task UpdateWorkspace_WithValidData_ReturnsUpdated() { - // Arrange - var token = await GetAuthTokenAsync("update-ws@example.com"); + // Arrange - use the default workspace (created on registration) + var (token, workspaceId) = await GetAuthAndWorkspaceAsync("update-ws@example.com"); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Original Name" }); - var created = await createResponse.Content.ReadFromJsonAsync(); - // Act - var response = await _client.PutAsJsonAsync($"/workspaces/{created!.Id}", new { Name = "Updated Name" }); + var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}", new { Name = "Updated Name" }); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -139,8 +151,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory) [Fact] public async Task DeleteWorkspace_WithValidId_ReturnsSuccess() { - // Arrange - var token = await GetAuthTokenAsync("delete-ws@example.com"); + // Arrange - upgrade to Pro to allow creating additional workspaces + var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", upgradeToPro: true); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" }); diff --git a/src/api/Data/AppDbContext.cs b/src/api/Data/AppDbContext.cs index b0911d2..8bc0b79 100644 --- a/src/api/Data/AppDbContext.cs +++ b/src/api/Data/AppDbContext.cs @@ -14,6 +14,9 @@ public class AppDbContext(DbContextOptions options) public DbSet QrCodeDesigns => Set(); public DbSet Events => Set(); public DbSet Assets => Set(); + public DbSet PasswordResetTokens => Set(); + public DbSet EmailVerificationTokens => Set(); + public DbSet ApiKeys => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -177,5 +180,49 @@ public class AppDbContext(DbContextOptions options) .HasForeignKey(e => e.WorkspaceId) .OnDelete(DeleteBehavior.Cascade); }); + + // PasswordResetToken configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Token).IsUnique(); + entity.Property(e => e.Token).HasMaxLength(64); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // EmailVerificationToken configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Token).IsUnique(); + entity.Property(e => e.Token).HasMaxLength(64); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // ApiKey configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.KeyHash).IsUnique(); + entity.Property(e => e.Name).HasMaxLength(100); + entity.Property(e => e.KeyHash).HasMaxLength(64); + entity.Property(e => e.KeyPrefix).HasMaxLength(16); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + + entity.HasOne(e => e.Workspace) + .WithMany() + .HasForeignKey(e => e.WorkspaceId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/src/api/Features/Analytics/Common/AnalyticsResponses.cs b/src/api/Features/Analytics/Common/AnalyticsResponses.cs index 7cb996b..fff1253 100644 --- a/src/api/Features/Analytics/Common/AnalyticsResponses.cs +++ b/src/api/Features/Analytics/Common/AnalyticsResponses.cs @@ -25,7 +25,8 @@ public record WorkspaceAnalyticsResponse( IEnumerable TimeSeries, IEnumerable TopLinks, IEnumerable DeviceBreakdown, - IEnumerable ReferrerBreakdown + IEnumerable ReferrerBreakdown, + IEnumerable CountryBreakdown ); public record LinkAnalyticsResponse( @@ -34,5 +35,6 @@ public record LinkAnalyticsResponse( AnalyticsSummary Summary, IEnumerable TimeSeries, IEnumerable DeviceBreakdown, - IEnumerable ReferrerBreakdown + IEnumerable ReferrerBreakdown, + IEnumerable CountryBreakdown ); diff --git a/src/api/Features/Analytics/Endpoints/LinkAnalyticsEndpoint.cs b/src/api/Features/Analytics/Endpoints/LinkAnalyticsEndpoint.cs index 625d132..1e2dd3c 100644 --- a/src/api/Features/Analytics/Endpoints/LinkAnalyticsEndpoint.cs +++ b/src/api/Features/Analytics/Endpoints/LinkAnalyticsEndpoint.cs @@ -13,6 +13,8 @@ public class LinkAnalyticsRequest public Guid WorkspaceId { get; set; } public Guid Id { get; set; } public string? Period { get; set; } // 24h, 7d, 30d, or null for all time + public DateTime? StartDate { get; set; } // Custom date range start + public DateTime? EndDate { get; set; } // Custom date range end } public class LinkAnalyticsEndpoint(AppDbContext db) @@ -39,8 +41,19 @@ public class LinkAnalyticsEndpoint(AppDbContext db) return; } - // Determine time filter - var startDate = GetStartDate(req.Period); + // Determine time filter (custom range takes precedence over period) + DateTime? startDate = null; + DateTime? endDate = null; + + if (req.StartDate.HasValue && req.EndDate.HasValue) + { + startDate = req.StartDate.Value; + endDate = req.EndDate.Value.AddDays(1); // Include the entire end day + } + else + { + startDate = GetStartDate(req.Period); + } // Query events for this link var eventsQuery = db.Events @@ -51,6 +64,11 @@ public class LinkAnalyticsEndpoint(AppDbContext db) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value); } + if (endDate.HasValue) + { + eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value); + } + var events = await eventsQuery.ToListAsync(ct); var totalEvents = events.Count; @@ -99,13 +117,27 @@ public class LinkAnalyticsEndpoint(AppDbContext db) )) .ToList(); + // Country breakdown + var countryBreakdown = events + .Where(e => !string.IsNullOrEmpty(e.CountryCode)) + .GroupBy(e => e.CountryCode!) + .OrderByDescending(g => g.Count()) + .Take(10) + .Select(g => new BreakdownItem( + g.Key, + g.Count(), + totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0 + )) + .ToList(); + var response = new LinkAnalyticsResponse( LinkId: link.Id, Slug: link.Slug, Summary: summary, TimeSeries: timeSeries, DeviceBreakdown: deviceBreakdown, - ReferrerBreakdown: referrerBreakdown + ReferrerBreakdown: referrerBreakdown, + CountryBreakdown: countryBreakdown ); await HttpContext.Response.SendAsync(response, 200, cancellation: ct); diff --git a/src/api/Features/Analytics/Endpoints/WorkspaceAnalyticsEndpoint.cs b/src/api/Features/Analytics/Endpoints/WorkspaceAnalyticsEndpoint.cs index d745163..f69cd91 100644 --- a/src/api/Features/Analytics/Endpoints/WorkspaceAnalyticsEndpoint.cs +++ b/src/api/Features/Analytics/Endpoints/WorkspaceAnalyticsEndpoint.cs @@ -12,6 +12,8 @@ public class WorkspaceAnalyticsRequest { public Guid WorkspaceId { get; set; } public string? Period { get; set; } // 24h, 7d, 30d, or null for all time + public DateTime? StartDate { get; set; } // Custom date range start + public DateTime? EndDate { get; set; } // Custom date range end } public class WorkspaceAnalyticsEndpoint(AppDbContext db) @@ -36,8 +38,19 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db) return; } - // Determine time filter - var startDate = GetStartDate(req.Period); + // Determine time filter (custom range takes precedence over period) + DateTime? startDate = null; + DateTime? endDate = null; + + if (req.StartDate.HasValue && req.EndDate.HasValue) + { + startDate = req.StartDate.Value; + endDate = req.EndDate.Value.AddDays(1); // Include the entire end day + } + else + { + startDate = GetStartDate(req.Period); + } // Query events var eventsQuery = db.Events @@ -48,6 +61,11 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db) eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value); } + if (endDate.HasValue) + { + eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value); + } + var events = await eventsQuery.ToListAsync(ct); var totalEvents = events.Count; @@ -114,12 +132,26 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db) )) .ToList(); + // Get country breakdown + var countryBreakdown = events + .Where(e => !string.IsNullOrEmpty(e.CountryCode)) + .GroupBy(e => e.CountryCode!) + .OrderByDescending(g => g.Count()) + .Take(10) + .Select(g => new BreakdownItem( + g.Key, + g.Count(), + totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0 + )) + .ToList(); + var response = new WorkspaceAnalyticsResponse( Summary: summary, TimeSeries: timeSeries, TopLinks: topLinks, DeviceBreakdown: deviceBreakdown, - ReferrerBreakdown: referrerBreakdown + ReferrerBreakdown: referrerBreakdown, + CountryBreakdown: countryBreakdown ); await HttpContext.Response.SendAsync(response, 200, cancellation: ct); diff --git a/src/api/Features/ApiKeys/Endpoints/CreateApiKeyEndpoint.cs b/src/api/Features/ApiKeys/Endpoints/CreateApiKeyEndpoint.cs new file mode 100644 index 0000000..efb8ac7 --- /dev/null +++ b/src/api/Features/ApiKeys/Endpoints/CreateApiKeyEndpoint.cs @@ -0,0 +1,102 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using api.Data; +using api.Features.Auth.Common; +using api.Models; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.ApiKeys.Endpoints; + +public class CreateApiKeyRequest +{ + public Guid WorkspaceId { get; set; } + public required string Name { get; set; } + public DateTime? ExpiresAt { get; set; } + public List? Scopes { get; set; } +} + +public class CreateApiKeyResponse +{ + public required Guid Id { get; set; } + public required string Name { get; set; } + public required string Key { get; set; } // Only returned once on creation! + public required string KeyPrefix { get; set; } + public List? Scopes { get; set; } + public DateTime? ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class CreateApiKeyEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Post("/workspaces/{WorkspaceId}/api-keys"); + } + + public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + // Verify workspace ownership + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (workspace is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + // Check API key limit (max 10 per workspace) + var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct); + if (existingCount >= 10) + { + await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400, cancellation: ct); + return; + } + + // Generate secure key: trk_<32 random bytes as base64url> + var randomBytes = RandomNumberGenerator.GetBytes(32); + var keyValue = "trk_" + Convert.ToBase64String(randomBytes).Replace("+", "-").Replace("/", "_").TrimEnd('='); + var keyHash = ComputeSha256Hash(keyValue); + var keyPrefix = keyValue[..12] + "..."; + + var apiKey = new ApiKey + { + Id = Guid.NewGuid(), + WorkspaceId = req.WorkspaceId, + Name = req.Name, + KeyHash = keyHash, + KeyPrefix = keyPrefix, + Scopes = req.Scopes, + ExpiresAt = req.ExpiresAt, + CreatedAt = DateTime.UtcNow, + IsActive = true, + }; + + db.ApiKeys.Add(apiKey); + await db.SaveChangesAsync(ct); + + var response = new CreateApiKeyResponse + { + Id = apiKey.Id, + Name = apiKey.Name, + Key = keyValue, // Only returned once! + KeyPrefix = keyPrefix, + Scopes = apiKey.Scopes, + ExpiresAt = apiKey.ExpiresAt, + CreatedAt = apiKey.CreatedAt, + }; + + await HttpContext.Response.SendAsync(response, 201, cancellation: ct); + } + + private static string ComputeSha256Hash(string input) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(input); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLower(); + } +} diff --git a/src/api/Features/ApiKeys/Endpoints/DeleteApiKeyEndpoint.cs b/src/api/Features/ApiKeys/Endpoints/DeleteApiKeyEndpoint.cs new file mode 100644 index 0000000..d5f6347 --- /dev/null +++ b/src/api/Features/ApiKeys/Endpoints/DeleteApiKeyEndpoint.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.ApiKeys.Endpoints; + +public class DeleteApiKeyRequest +{ + public Guid WorkspaceId { get; set; } + public Guid Id { get; set; } +} + +public class DeleteApiKeyEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Delete("/workspaces/{WorkspaceId}/api-keys/{Id}"); + } + + public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + // Verify workspace ownership + var workspaceExists = await db.Workspaces + .AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (!workspaceExists) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + var apiKey = await db.ApiKeys + .FirstOrDefaultAsync(k => k.Id == req.Id && k.WorkspaceId == req.WorkspaceId, ct); + + if (apiKey is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("API key not found"), 404, cancellation: ct); + return; + } + + db.ApiKeys.Remove(apiKey); + await db.SaveChangesAsync(ct); + + await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), 200, cancellation: ct); + } +} diff --git a/src/api/Features/ApiKeys/Endpoints/ListApiKeysEndpoint.cs b/src/api/Features/ApiKeys/Endpoints/ListApiKeysEndpoint.cs new file mode 100644 index 0000000..17a7e95 --- /dev/null +++ b/src/api/Features/ApiKeys/Endpoints/ListApiKeysEndpoint.cs @@ -0,0 +1,72 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.ApiKeys.Endpoints; + +public class ListApiKeysRequest +{ + public Guid WorkspaceId { get; set; } +} + +public class ApiKeyDto +{ + public required Guid Id { get; set; } + public required string Name { get; set; } + public required string KeyPrefix { get; set; } + public List? Scopes { get; set; } + public DateTime? ExpiresAt { get; set; } + public DateTime? LastUsedAt { get; set; } + public DateTime CreatedAt { get; set; } + public bool IsActive { get; set; } +} + +public class ListApiKeysResponse +{ + public required List ApiKeys { get; set; } +} + +public class ListApiKeysEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Get("/workspaces/{WorkspaceId}/api-keys"); + } + + public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + // Verify workspace ownership + var workspaceExists = await db.Workspaces + .AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (!workspaceExists) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + var apiKeys = await db.ApiKeys + .Where(k => k.WorkspaceId == req.WorkspaceId) + .OrderByDescending(k => k.CreatedAt) + .Select(k => new ApiKeyDto + { + Id = k.Id, + Name = k.Name, + KeyPrefix = k.KeyPrefix, + Scopes = k.Scopes, + ExpiresAt = k.ExpiresAt, + LastUsedAt = k.LastUsedAt, + CreatedAt = k.CreatedAt, + IsActive = k.IsActive, + }) + .ToListAsync(ct); + + var response = new ListApiKeysResponse { ApiKeys = apiKeys }; + await HttpContext.Response.SendAsync(response, 200, cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/ChangePasswordEndpoint.cs b/src/api/Features/Auth/Endpoints/ChangePasswordEndpoint.cs new file mode 100644 index 0000000..b3d0525 --- /dev/null +++ b/src/api/Features/Auth/Endpoints/ChangePasswordEndpoint.cs @@ -0,0 +1,59 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using FluentValidation; + +namespace api.Features.Auth.Endpoints; + +public class ChangePasswordRequest +{ + public string CurrentPassword { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public class ChangePasswordValidator : Validator +{ + public ChangePasswordValidator() + { + RuleFor(x => x.CurrentPassword) + .NotEmpty().WithMessage("Current password is required"); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("New password is required") + .MinimumLength(8).WithMessage("New password must be at least 8 characters"); + } +} + +public class ChangePasswordEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Post("/auth/change-password"); + } + + public override async Task HandleAsync(ChangePasswordRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var user = await db.Users.FindAsync([userId], ct); + if (user == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct); + return; + } + + // Verify current password + if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash)) + { + await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400, cancellation: ct); + return; + } + + // Update password + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword); + await db.SaveChangesAsync(ct); + + await HttpContext.Response.SendAsync(new MessageResponse("Password changed successfully"), cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/DeleteAccountEndpoint.cs b/src/api/Features/Auth/Endpoints/DeleteAccountEndpoint.cs new file mode 100644 index 0000000..3766aaa --- /dev/null +++ b/src/api/Features/Auth/Endpoints/DeleteAccountEndpoint.cs @@ -0,0 +1,59 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Auth.Endpoints; + +public class DeleteAccountRequest +{ + public string Password { get; set; } = string.Empty; +} + +public class DeleteAccountValidator : Validator +{ + public DeleteAccountValidator() + { + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required to delete account"); + } +} + +public class DeleteAccountEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Delete("/auth/account"); + } + + public override async Task HandleAsync(DeleteAccountRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var user = await db.Users.FindAsync([userId], ct); + if (user == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct); + return; + } + + // Verify password + if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) + { + await HttpContext.Response.SendAsync(new MessageResponse("Password is incorrect"), 400, cancellation: ct); + return; + } + + // Delete all user's workspaces (cascade will handle related data) + var workspaces = await db.Workspaces.Where(w => w.OwnerUserId == userId).ToListAsync(ct); + db.Workspaces.RemoveRange(workspaces); + + // Delete user + db.Users.Remove(user); + await db.SaveChangesAsync(ct); + + await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs b/src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs index 12c53a6..838893d 100644 --- a/src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs +++ b/src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs @@ -1,6 +1,8 @@ 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; @@ -22,13 +24,14 @@ public class ForgotPasswordValidator : Validator } } -public class ForgotPasswordEndpoint(AppDbContext db) +public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService) : Endpoint { public override void Configure() { Post("/auth/forgot"); AllowAnonymous(); + Options(x => x.RequireRateLimiting("auth")); } public override async Task HandleAsync(ForgotPasswordRequest req, CancellationToken ct) @@ -36,20 +39,55 @@ public class ForgotPasswordEndpoint(AppDbContext db) var normalizedEmail = req.Email.ToLowerInvariant(); var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct); - if (user == null) + if (user != null) { - Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail); + // Invalidate any existing tokens for this user + var existingTokens = await db.PasswordResetTokens + .Where(t => t.UserId == user.Id && !t.Used) + .ToListAsync(ct); + + foreach (var token in existingTokens) + { + token.Used = true; + } + + // Generate new token + var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .Replace("+", "-").Replace("/", "_").TrimEnd('='); + + var passwordResetToken = new PasswordResetToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + Token = resetToken, + ExpiresAt = DateTime.UtcNow.AddHours(1), + Used = false, + CreatedAt = DateTime.UtcNow + }; + + db.PasswordResetTokens.Add(passwordResetToken); + await db.SaveChangesAsync(ct); + + // Send password reset email + try + { + await emailService.SendPasswordResetEmailAsync(normalizedEmail, resetToken, ct); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to send password reset email to {Email}", normalizedEmail); + // Don't fail the request - still return success to prevent email enumeration + } } else { - var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - // TODO: Store reset token in database with expiration - // TODO: Send email with reset link - Logger.LogInformation("Password reset token generated for: {Email}, Token: {Token}", normalizedEmail, resetToken); + Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail); } // Always return success to prevent email enumeration - await HttpContext.Response.SendAsync(new MessageResponse("If the email exists, a reset link will be sent"), 200, cancellation: ct); + await HttpContext.Response.SendAsync( + new MessageResponse("If the email exists, a reset link will be sent"), + 200, + cancellation: ct); } } diff --git a/src/api/Features/Auth/Endpoints/GetProfileEndpoint.cs b/src/api/Features/Auth/Endpoints/GetProfileEndpoint.cs new file mode 100644 index 0000000..680131e --- /dev/null +++ b/src/api/Features/Auth/Endpoints/GetProfileEndpoint.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using api.Data; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Auth.Endpoints; + +public record ProfileResponse( + Guid Id, + string Email, + bool IsVerified, + DateTime CreatedAt +); + +public class GetProfileEndpoint(AppDbContext db) : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/auth/profile"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var user = await db.Users + .Where(u => u.Id == userId) + .Select(u => new ProfileResponse( + u.Id, + u.Email, + u.VerifiedAt != null, + u.CreatedAt + )) + .FirstOrDefaultAsync(ct); + + if (user == null) + { + await HttpContext.Response.SendAsync(new { message = "User not found" }, 404, cancellation: ct); + return; + } + + await HttpContext.Response.SendAsync(user, cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/LoginEndpoint.cs b/src/api/Features/Auth/Endpoints/LoginEndpoint.cs index a4ba6a7..7262880 100644 --- a/src/api/Features/Auth/Endpoints/LoginEndpoint.cs +++ b/src/api/Features/Auth/Endpoints/LoginEndpoint.cs @@ -40,6 +40,7 @@ public class LoginEndpoint(AppDbContext db, IOptions jwtSettings) { Post("/auth/login"); AllowAnonymous(); + Options(x => x.RequireRateLimiting("auth")); } public override async Task HandleAsync(LoginRequest req, CancellationToken ct) diff --git a/src/api/Features/Auth/Endpoints/RegisterEndpoint.cs b/src/api/Features/Auth/Endpoints/RegisterEndpoint.cs index 650ad16..2e8c8e1 100644 --- a/src/api/Features/Auth/Endpoints/RegisterEndpoint.cs +++ b/src/api/Features/Auth/Endpoints/RegisterEndpoint.cs @@ -1,9 +1,11 @@ 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; @@ -35,7 +37,7 @@ public class RegisterValidator : Validator } } -public class RegisterEndpoint(AppDbContext db, IOptions jwtSettings) +public class RegisterEndpoint(AppDbContext db, IOptions jwtSettings, IEmailService emailService) : Endpoint { private readonly JwtSettings _jwtSettings = jwtSettings.Value; @@ -44,6 +46,7 @@ public class RegisterEndpoint(AppDbContext db, IOptions jwtSettings { Post("/auth/register"); AllowAnonymous(); + Options(x => x.RequireRateLimiting("auth")); } public override async Task HandleAsync(RegisterRequest req, CancellationToken ct) @@ -76,8 +79,29 @@ public class RegisterEndpoint(AppDbContext db, IOptions jwtSettings }; db.Workspaces.Add(workspace); + + // Create email verification token + var tokenBytes = RandomNumberGenerator.GetBytes(32); + var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant(); + + var verificationToken = new EmailVerificationToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + Token = tokenString, + ExpiresAt = DateTime.UtcNow.AddHours(24), + CreatedAt = DateTime.UtcNow + }; + + db.EmailVerificationTokens.Add(verificationToken); await db.SaveChangesAsync(ct); + // Send verification email (fire and forget) + _ = emailService.SendEmailVerificationAsync(normalizedEmail, tokenString, ct); + + // Send welcome email + _ = emailService.SendWelcomeEmailAsync(normalizedEmail, normalizedEmail.Split('@')[0], ct); + Logger.LogInformation("User registered: {Email}", normalizedEmail); var response = GenerateAuthResponse(user); diff --git a/src/api/Features/Auth/Endpoints/ResendVerificationEndpoint.cs b/src/api/Features/Auth/Endpoints/ResendVerificationEndpoint.cs new file mode 100644 index 0000000..f2f2c2e --- /dev/null +++ b/src/api/Features/Auth/Endpoints/ResendVerificationEndpoint.cs @@ -0,0 +1,63 @@ +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; + +namespace api.Features.Auth.Endpoints; + +public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/auth/resend-verification"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var user = await db.Users.FindAsync([userId], ct); + if (user == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct); + return; + } + + if (user.VerifiedAt != null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400, cancellation: ct); + return; + } + + // Remove existing tokens + var existingTokens = await db.EmailVerificationTokens + .Where(t => t.UserId == userId) + .ToListAsync(ct); + db.EmailVerificationTokens.RemoveRange(existingTokens); + + // Create new token + var tokenBytes = RandomNumberGenerator.GetBytes(32); + var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant(); + + var verificationToken = new EmailVerificationToken + { + Id = Guid.NewGuid(), + UserId = userId, + Token = tokenString, + ExpiresAt = DateTime.UtcNow.AddHours(24), + CreatedAt = DateTime.UtcNow + }; + + db.EmailVerificationTokens.Add(verificationToken); + await db.SaveChangesAsync(ct); + + // Send verification email + await emailService.SendEmailVerificationAsync(user.Email, tokenString, ct); + + await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs b/src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs index fe1fe07..2ed19dc 100644 --- a/src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs +++ b/src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs @@ -1,6 +1,8 @@ +using api.Data; using api.Features.Auth.Common; using FastEndpoints; using FluentValidation; +using Microsoft.EntityFrameworkCore; namespace api.Features.Auth.Endpoints; @@ -24,23 +26,63 @@ public class ValidatorResetPassword : Validator } } -public class ResetPasswordEndpoint : Endpoint +public class ResetPasswordEndpoint(AppDbContext db) + : Endpoint { public override void Configure() { Post("/auth/reset"); AllowAnonymous(); + Options(x => x.RequireRateLimiting("auth")); } public override async Task HandleAsync(ResetPasswordRequest req, CancellationToken ct) { - // TODO: Implement password reset - // 1. Look up token in database - // 2. Verify token hasn't expired - // 3. Get associated user - // 4. Update password - // 5. Invalidate token + // Find the token + var resetToken = await db.PasswordResetTokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.Token == req.Token, ct); - await HttpContext.Response.SendAsync(new MessageResponse("Password reset is not yet available"), 400, cancellation: ct); + if (resetToken == null) + { + await HttpContext.Response.SendAsync( + new MessageResponse("Invalid or expired reset token"), + 400, + cancellation: ct); + return; + } + + // Check if token is expired + if (resetToken.ExpiresAt < DateTime.UtcNow) + { + await HttpContext.Response.SendAsync( + new MessageResponse("Reset token has expired"), + 400, + cancellation: ct); + return; + } + + // Check if token is already used + if (resetToken.Used) + { + await HttpContext.Response.SendAsync( + new MessageResponse("Reset token has already been used"), + 400, + cancellation: ct); + return; + } + + // Update the user's password + resetToken.User.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword); + resetToken.Used = true; + + await db.SaveChangesAsync(ct); + + Logger.LogInformation("Password reset successful for user: {Email}", resetToken.User.Email); + + await HttpContext.Response.SendAsync( + new MessageResponse("Password has been reset successfully"), + 200, + cancellation: ct); } } diff --git a/src/api/Features/Auth/Endpoints/UpdateProfileEndpoint.cs b/src/api/Features/Auth/Endpoints/UpdateProfileEndpoint.cs new file mode 100644 index 0000000..04f76dd --- /dev/null +++ b/src/api/Features/Auth/Endpoints/UpdateProfileEndpoint.cs @@ -0,0 +1,68 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Auth.Endpoints; + +public class UpdateProfileRequest +{ + public string? Email { get; set; } +} + +public class UpdateProfileValidator : Validator +{ + public UpdateProfileValidator() + { + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Invalid email address") + .When(x => !string.IsNullOrEmpty(x.Email)); + } +} + +public class UpdateProfileEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Put("/auth/profile"); + } + + public override async Task HandleAsync(UpdateProfileRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var user = await db.Users.FindAsync([userId], ct); + if (user == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct); + return; + } + + if (!string.IsNullOrEmpty(req.Email) && req.Email != user.Email) + { + // Check if email is already taken + var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct); + if (emailExists) + { + await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409, cancellation: ct); + return; + } + + user.Email = req.Email; + user.VerifiedAt = null; // Reset verification when email changes + } + + await db.SaveChangesAsync(ct); + + var response = new ProfileResponse( + user.Id, + user.Email, + user.VerifiedAt != null, + user.CreatedAt + ); + + await HttpContext.Response.SendAsync(response, cancellation: ct); + } +} diff --git a/src/api/Features/Auth/Endpoints/VerifyEmailEndpoint.cs b/src/api/Features/Auth/Endpoints/VerifyEmailEndpoint.cs new file mode 100644 index 0000000..a2d234a --- /dev/null +++ b/src/api/Features/Auth/Endpoints/VerifyEmailEndpoint.cs @@ -0,0 +1,59 @@ +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Auth.Endpoints; + +public class VerifyEmailRequest +{ + public string Token { get; set; } = string.Empty; +} + +public class VerifyEmailValidator : Validator +{ + public VerifyEmailValidator() + { + RuleFor(x => x.Token).NotEmpty().WithMessage("Token is required"); + } +} + +public class VerifyEmailEndpoint(AppDbContext db) : Endpoint +{ + public override void Configure() + { + Post("/auth/verify-email"); + AllowAnonymous(); + } + + public override async Task HandleAsync(VerifyEmailRequest req, CancellationToken ct) + { + var token = await db.EmailVerificationTokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.Token == req.Token, ct); + + if (token == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400, cancellation: ct); + return; + } + + if (token.ExpiresAt < DateTime.UtcNow) + { + db.EmailVerificationTokens.Remove(token); + await db.SaveChangesAsync(ct); + await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400, cancellation: ct); + return; + } + + // Mark user as verified + token.User.VerifiedAt = DateTime.UtcNow; + + // Remove the token + db.EmailVerificationTokens.Remove(token); + await db.SaveChangesAsync(ct); + + await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct); + } +} diff --git a/src/api/Features/Billing/Common/BillingModels.cs b/src/api/Features/Billing/Common/BillingModels.cs new file mode 100644 index 0000000..307ce0a --- /dev/null +++ b/src/api/Features/Billing/Common/BillingModels.cs @@ -0,0 +1,23 @@ +namespace api.Features.Billing.Common; + +public record CheckoutSessionRequest( + Guid WorkspaceId, + string Plan, + string SuccessUrl, + string CancelUrl +); + +public record CheckoutSessionResponse(string Url); + +public record PortalSessionRequest(string ReturnUrl); + +public record PortalSessionResponse(string Url); + +public record SubscriptionResponse( + Guid WorkspaceId, + string Plan, + string? SubscriptionId, + DateTime? CurrentPeriodEnd, + bool IsActive, + bool CancelAtPeriodEnd +); diff --git a/src/api/Features/Billing/Endpoints/CreateCheckoutSessionEndpoint.cs b/src/api/Features/Billing/Endpoints/CreateCheckoutSessionEndpoint.cs new file mode 100644 index 0000000..85987d4 --- /dev/null +++ b/src/api/Features/Billing/Endpoints/CreateCheckoutSessionEndpoint.cs @@ -0,0 +1,87 @@ +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; + +namespace api.Features.Billing.Endpoints; + +public class CreateCheckoutSessionValidator : Validator +{ + public CreateCheckoutSessionValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.Plan) + .NotEmpty() + .Must(p => p == "Pro" || p == "Business") + .WithMessage("Plan must be 'Pro' or 'Business'"); + RuleFor(x => x.SuccessUrl).NotEmpty().Must(BeValidUrl); + RuleFor(x => x.CancelUrl).NotEmpty().Must(BeValidUrl); + } + + private static bool BeValidUrl(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } +} + +public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService stripeService) + : Endpoint +{ + public override void Configure() + { + Post("/billing/checkout"); + } + + public override async Task HandleAsync(CheckoutSessionRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + // Verify workspace ownership + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (workspace == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + // Check if already subscribed + if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId)) + { + await HttpContext.Response.SendAsync( + new MessageResponse("Workspace already has an active subscription. Use the billing portal to manage it."), + 400, + cancellation: ct); + return; + } + + var plan = Enum.Parse(req.Plan); + + try + { + var checkoutUrl = await stripeService.CreateCheckoutSessionAsync( + userId, + req.WorkspaceId, + plan, + req.SuccessUrl, + req.CancelUrl, + ct); + + await HttpContext.Response.SendAsync(new CheckoutSessionResponse(checkoutUrl), cancellation: ct); + } + catch (Exception ex) + { + await HttpContext.Response.SendAsync( + new MessageResponse($"Failed to create checkout session: {ex.Message}"), + 500, + cancellation: ct); + } + } +} diff --git a/src/api/Features/Billing/Endpoints/CreatePortalSessionEndpoint.cs b/src/api/Features/Billing/Endpoints/CreatePortalSessionEndpoint.cs new file mode 100644 index 0000000..7d5ba8f --- /dev/null +++ b/src/api/Features/Billing/Endpoints/CreatePortalSessionEndpoint.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using api.Features.Auth.Common; +using api.Features.Billing.Common; +using api.Features.Billing.Services; +using FastEndpoints; +using FluentValidation; + +namespace api.Features.Billing.Endpoints; + +public class CreatePortalSessionValidator : Validator +{ + public CreatePortalSessionValidator() + { + RuleFor(x => x.ReturnUrl).NotEmpty().Must(BeValidUrl); + } + + private static bool BeValidUrl(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } +} + +public class CreatePortalSessionEndpoint(IStripeService stripeService) + : Endpoint +{ + public override void Configure() + { + Post("/billing/portal"); + } + + public override async Task HandleAsync(PortalSessionRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + try + { + var portalUrl = await stripeService.CreateCustomerPortalSessionAsync( + userId, + req.ReturnUrl, + ct); + + await HttpContext.Response.SendAsync(new PortalSessionResponse(portalUrl), cancellation: ct); + } + catch (InvalidOperationException ex) + { + await HttpContext.Response.SendAsync( + new MessageResponse(ex.Message), + 400, + cancellation: ct); + } + catch (Exception ex) + { + await HttpContext.Response.SendAsync( + new MessageResponse($"Failed to create portal session: {ex.Message}"), + 500, + cancellation: ct); + } + } +} diff --git a/src/api/Features/Billing/Endpoints/GetSubscriptionEndpoint.cs b/src/api/Features/Billing/Endpoints/GetSubscriptionEndpoint.cs new file mode 100644 index 0000000..b9a7bc0 --- /dev/null +++ b/src/api/Features/Billing/Endpoints/GetSubscriptionEndpoint.cs @@ -0,0 +1,62 @@ +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; + +namespace api.Features.Billing.Endpoints; + +public class GetSubscriptionRequest +{ + public Guid WorkspaceId { get; set; } +} + +public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeService) + : Endpoint +{ + public override void Configure() + { + Get("/workspaces/{WorkspaceId}/subscription"); + } + + public override async Task HandleAsync(GetSubscriptionRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (workspace == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + var isActive = workspace.Plan != Models.WorkspacePlan.Free; + var cancelAtPeriodEnd = false; + + // Get live subscription status from Stripe if exists + if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId)) + { + var subscription = await stripeService.GetSubscriptionAsync(workspace.StripeSubscriptionId, ct); + if (subscription != null) + { + isActive = subscription.Status == "active" || subscription.Status == "trialing"; + cancelAtPeriodEnd = subscription.CancelAtPeriodEnd; + } + } + + var response = new SubscriptionResponse( + workspace.Id, + workspace.Plan.ToString(), + workspace.StripeSubscriptionId, + workspace.SubscriptionEndsAt, + isActive, + cancelAtPeriodEnd + ); + + await HttpContext.Response.SendAsync(response, cancellation: ct); + } +} diff --git a/src/api/Features/Billing/Endpoints/StripeWebhookEndpoint.cs b/src/api/Features/Billing/Endpoints/StripeWebhookEndpoint.cs new file mode 100644 index 0000000..e889f0a --- /dev/null +++ b/src/api/Features/Billing/Endpoints/StripeWebhookEndpoint.cs @@ -0,0 +1,87 @@ +using api.Features.Billing.Services; +using api.Features.Billing.Settings; +using FastEndpoints; +using Microsoft.Extensions.Options; +using Stripe; + +namespace api.Features.Billing.Endpoints; + +public class StripeWebhookEndpoint( + IStripeService stripeService, + IOptions settings, + ILogger logger) + : EndpointWithoutRequest +{ + private readonly StripeSettings _settings = settings.Value; + + public override void Configure() + { + Post("/billing/webhook"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(ct); + + try + { + var stripeSignature = HttpContext.Request.Headers["Stripe-Signature"].ToString(); + var stripeEvent = EventUtility.ConstructEvent( + json, + stripeSignature, + _settings.WebhookSecret + ); + + logger.LogInformation("Received Stripe event: {EventType} ({EventId})", stripeEvent.Type, stripeEvent.Id); + + switch (stripeEvent.Type) + { + case "checkout.session.completed": + var session = stripeEvent.Data.Object as Stripe.Checkout.Session; + if (session != null) + { + await stripeService.HandleCheckoutCompletedAsync(session, ct); + } + break; + + case "customer.subscription.updated": + var updatedSubscription = stripeEvent.Data.Object as Subscription; + if (updatedSubscription != null) + { + await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct); + } + break; + + case "customer.subscription.deleted": + var deletedSubscription = stripeEvent.Data.Object as Subscription; + if (deletedSubscription != null) + { + await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct); + } + break; + + case "invoice.payment_failed": + logger.LogWarning("Payment failed for invoice: {InvoiceId}", + (stripeEvent.Data.Object as Invoice)?.Id); + break; + + default: + logger.LogDebug("Unhandled Stripe event type: {EventType}", stripeEvent.Type); + break; + } + + await HttpContext.Response.SendAsync(new { received = true }, cancellation: ct); + } + catch (StripeException ex) + { + logger.LogError(ex, "Stripe webhook signature verification failed"); + await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400, cancellation: ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing Stripe webhook"); + await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct); + } + } +} diff --git a/src/api/Features/Billing/Services/StripeService.cs b/src/api/Features/Billing/Services/StripeService.cs new file mode 100644 index 0000000..a7c5915 --- /dev/null +++ b/src/api/Features/Billing/Services/StripeService.cs @@ -0,0 +1,302 @@ +using api.Data; +using api.Features.Billing.Settings; +using api.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Stripe; +using Stripe.Checkout; + +namespace api.Features.Billing.Services; + +public interface IStripeService +{ + Task CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl, string cancelUrl, CancellationToken ct = default); + Task CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default); + Task GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default); + Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default); + Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default); + Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default); + Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default); + string GetPriceIdForPlan(WorkspacePlan plan); + WorkspacePlan GetPlanForPriceId(string priceId); +} + +public class StripeService : IStripeService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly StripeSettings _settings; + private readonly ILogger _logger; + + public StripeService( + IServiceScopeFactory scopeFactory, + IOptions settings, + ILogger logger) + { + _scopeFactory = scopeFactory; + _settings = settings.Value; + _logger = logger; + + StripeConfiguration.ApiKey = _settings.SecretKey; + } + + public async Task CreateCheckoutSessionAsync( + Guid userId, + Guid workspaceId, + WorkspacePlan plan, + string successUrl, + string cancelUrl, + CancellationToken ct = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync([userId], ct) + ?? throw new InvalidOperationException("User not found"); + + // Get or create Stripe customer + var customerId = user.StripeCustomerId; + if (string.IsNullOrEmpty(customerId)) + { + var customerService = new CustomerService(); + var customer = await customerService.CreateAsync(new CustomerCreateOptions + { + Email = user.Email, + Metadata = new Dictionary + { + ["user_id"] = userId.ToString() + } + }, cancellationToken: ct); + + customerId = customer.Id; + user.StripeCustomerId = customerId; + await db.SaveChangesAsync(ct); + } + + var priceId = GetPriceIdForPlan(plan); + if (string.IsNullOrEmpty(priceId)) + { + throw new InvalidOperationException($"No price configured for plan: {plan}"); + } + + var sessionService = new SessionService(); + var session = await sessionService.CreateAsync(new SessionCreateOptions + { + Customer = customerId, + Mode = "subscription", + PaymentMethodTypes = ["card"], + LineItems = + [ + new SessionLineItemOptions + { + Price = priceId, + Quantity = 1 + } + ], + SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}", + CancelUrl = cancelUrl, + Metadata = new Dictionary + { + ["user_id"] = userId.ToString(), + ["workspace_id"] = workspaceId.ToString(), + ["plan"] = plan.ToString() + }, + SubscriptionData = new SessionSubscriptionDataOptions + { + Metadata = new Dictionary + { + ["user_id"] = userId.ToString(), + ["workspace_id"] = workspaceId.ToString() + } + } + }, cancellationToken: ct); + + return session.Url; + } + + public async Task CreateCustomerPortalSessionAsync( + Guid userId, + string returnUrl, + CancellationToken ct = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync([userId], ct) + ?? throw new InvalidOperationException("User not found"); + + if (string.IsNullOrEmpty(user.StripeCustomerId)) + { + throw new InvalidOperationException("User has no Stripe customer"); + } + + var sessionService = new Stripe.BillingPortal.SessionService(); + var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions + { + Customer = user.StripeCustomerId, + ReturnUrl = returnUrl + }, cancellationToken: ct); + + return session.Url; + } + + public async Task GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default) + { + var service = new SubscriptionService(); + try + { + return await service.GetAsync(subscriptionId, cancellationToken: ct); + } + catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing") + { + return null; + } + } + + public async Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default) + { + var service = new SubscriptionService(); + await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions + { + InvoiceNow = false, + Prorate = false + }, cancellationToken: ct); + } + + public async Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default) + { + var workspaceIdStr = session.Metadata.GetValueOrDefault("workspace_id"); + var planStr = session.Metadata.GetValueOrDefault("plan"); + + if (string.IsNullOrEmpty(workspaceIdStr) || string.IsNullOrEmpty(planStr)) + { + _logger.LogWarning("Checkout session missing metadata: {SessionId}", session.Id); + return; + } + + if (!Guid.TryParse(workspaceIdStr, out var workspaceId)) + { + _logger.LogWarning("Invalid workspace_id in checkout session: {WorkspaceId}", workspaceIdStr); + return; + } + + if (!Enum.TryParse(planStr, out var plan)) + { + _logger.LogWarning("Invalid plan in checkout session: {Plan}", planStr); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var workspace = await db.Workspaces.FindAsync([workspaceId], ct); + if (workspace == null) + { + _logger.LogWarning("Workspace not found for checkout: {WorkspaceId}", workspaceId); + return; + } + + workspace.Plan = plan; + workspace.StripeSubscriptionId = session.SubscriptionId; + + // Get subscription to set end date + if (!string.IsNullOrEmpty(session.SubscriptionId)) + { + var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct); + if (subscription != null) + { + workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd; + } + } + + await db.SaveChangesAsync(ct); + + _logger.LogInformation( + "Workspace {WorkspaceId} upgraded to {Plan} via checkout {SessionId}", + workspaceId, plan, session.Id); + } + + public async Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct); + + if (workspace == null) + { + _logger.LogWarning("No workspace found for subscription: {SubscriptionId}", subscription.Id); + return; + } + + // Update plan based on price + var priceId = subscription.Items.Data.FirstOrDefault()?.Price?.Id; + if (!string.IsNullOrEmpty(priceId)) + { + var newPlan = GetPlanForPriceId(priceId); + if (workspace.Plan != newPlan) + { + _logger.LogInformation( + "Workspace {WorkspaceId} plan changed from {OldPlan} to {NewPlan}", + workspace.Id, workspace.Plan, newPlan); + workspace.Plan = newPlan; + } + } + + workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd; + + // Handle cancellation at period end + if (subscription.CancelAtPeriodEnd) + { + _logger.LogInformation( + "Workspace {WorkspaceId} subscription will cancel at {EndDate}", + workspace.Id, subscription.CurrentPeriodEnd); + } + + await db.SaveChangesAsync(ct); + } + + public async Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct); + + if (workspace == null) + { + _logger.LogWarning("No workspace found for deleted subscription: {SubscriptionId}", subscription.Id); + return; + } + + _logger.LogInformation( + "Workspace {WorkspaceId} subscription deleted, downgrading to Free", + workspace.Id); + + workspace.Plan = WorkspacePlan.Free; + workspace.StripeSubscriptionId = null; + workspace.SubscriptionEndsAt = null; + + await db.SaveChangesAsync(ct); + } + + public string GetPriceIdForPlan(WorkspacePlan plan) + { + return plan switch + { + WorkspacePlan.Pro => _settings.ProPriceId, + WorkspacePlan.Business => _settings.BusinessPriceId, + _ => string.Empty + }; + } + + public WorkspacePlan GetPlanForPriceId(string priceId) + { + if (priceId == _settings.ProPriceId) + return WorkspacePlan.Pro; + if (priceId == _settings.BusinessPriceId) + return WorkspacePlan.Business; + return WorkspacePlan.Free; + } +} diff --git a/src/api/Features/Billing/Settings/StripeSettings.cs b/src/api/Features/Billing/Settings/StripeSettings.cs new file mode 100644 index 0000000..bcf2658 --- /dev/null +++ b/src/api/Features/Billing/Settings/StripeSettings.cs @@ -0,0 +1,9 @@ +namespace api.Features.Billing.Settings; + +public class StripeSettings +{ + public string SecretKey { get; set; } = string.Empty; + public string WebhookSecret { get; set; } = string.Empty; + public string ProPriceId { get; set; } = string.Empty; + public string BusinessPriceId { get; set; } = string.Empty; +} diff --git a/src/api/Features/Domains/Endpoints/AddDomainEndpoint.cs b/src/api/Features/Domains/Endpoints/AddDomainEndpoint.cs index 9e12e74..6caacfe 100644 --- a/src/api/Features/Domains/Endpoints/AddDomainEndpoint.cs +++ b/src/api/Features/Domains/Endpoints/AddDomainEndpoint.cs @@ -3,6 +3,7 @@ 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; @@ -28,7 +29,7 @@ public class AddDomainValidator : Validator } } -public class AddDomainEndpoint(AppDbContext db) +public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits) : Endpoint { public override void Configure() @@ -50,6 +51,16 @@ public class AddDomainEndpoint(AppDbContext db) return; } + // Check plan limits + if (!await planLimits.CanCreateDomainAsync(req.WorkspaceId, ct)) + { + await HttpContext.Response.SendAsync( + new MessageResponse("Domain limit reached. Please upgrade your plan to add more custom domains."), + 402, + cancellation: ct); + return; + } + // Normalize hostname (lowercase, no trailing dots) var hostname = req.Hostname.ToLowerInvariant().TrimEnd('.'); diff --git a/src/api/Features/Email/Services/ConsoleEmailService.cs b/src/api/Features/Email/Services/ConsoleEmailService.cs new file mode 100644 index 0000000..6770402 --- /dev/null +++ b/src/api/Features/Email/Services/ConsoleEmailService.cs @@ -0,0 +1,60 @@ +using api.Features.Email.Templates; +using Microsoft.Extensions.Options; + +namespace api.Features.Email.Services; + +/// +/// Development email service that logs emails to console instead of sending them. +/// Useful for testing without a real SMTP server. +/// +public class ConsoleEmailService : IEmailService +{ + private readonly EmailSettings _settings; + private readonly ILogger _logger; + + public ConsoleEmailService(IOptions settings, ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default) + { + var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}"; + var (subject, _, textBody) = EmailTemplates.PasswordReset(resetUrl); + + LogEmail(toEmail, subject, textBody, resetUrl); + return Task.CompletedTask; + } + + public Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default) + { + var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}"; + var (subject, _, textBody) = EmailTemplates.EmailVerification(verifyUrl); + + LogEmail(toEmail, subject, textBody, verifyUrl); + return Task.CompletedTask; + } + + public Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default) + { + var dashboardUrl = $"{_settings.BaseUrl}/dashboard"; + var (subject, _, textBody) = EmailTemplates.Welcome(userName, dashboardUrl); + + LogEmail(toEmail, subject, textBody, dashboardUrl); + return Task.CompletedTask; + } + + private void LogEmail(string toEmail, string subject, string body, string actionUrl) + { + _logger.LogInformation($""" + + ╔══════════════════════════════════════════════════════════════╗ + ║ EMAIL (Console Mode) ║ + ╚══════════════════════════════════════════════════════════════╝ + To: {toEmail} + Subject: {subject} + Action URL: {actionUrl} + """); + } +} diff --git a/src/api/Features/Email/Services/IEmailService.cs b/src/api/Features/Email/Services/IEmailService.cs new file mode 100644 index 0000000..95e4ad9 --- /dev/null +++ b/src/api/Features/Email/Services/IEmailService.cs @@ -0,0 +1,36 @@ +namespace api.Features.Email.Services; + +public interface IEmailService +{ + Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default); + Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default); + Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default); +} + +public class EmailSettings +{ + public string Provider { get; set; } = "smtp"; // smtp, sendgrid, ses + public string FromEmail { get; set; } = "noreply@trakqr.com"; + public string FromName { get; set; } = "TrakQR"; + public string BaseUrl { get; set; } = "https://trakqr.com"; + + // SMTP settings + public SmtpSettings? Smtp { get; set; } + + // SendGrid settings + public SendGridSettings? SendGrid { get; set; } +} + +public class SmtpSettings +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 587; + public bool UseSsl { get; set; } = true; + public string? Username { get; set; } + public string? Password { get; set; } +} + +public class SendGridSettings +{ + public string ApiKey { get; set; } = string.Empty; +} diff --git a/src/api/Features/Email/Services/SmtpEmailService.cs b/src/api/Features/Email/Services/SmtpEmailService.cs new file mode 100644 index 0000000..0b940f1 --- /dev/null +++ b/src/api/Features/Email/Services/SmtpEmailService.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Mail; +using api.Features.Email.Templates; +using Microsoft.Extensions.Options; + +namespace api.Features.Email.Services; + +public class SmtpEmailService : IEmailService +{ + private readonly EmailSettings _settings; + private readonly ILogger _logger; + + public SmtpEmailService(IOptions settings, ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + public async Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default) + { + var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}"; + var (subject, htmlBody, textBody) = EmailTemplates.PasswordReset(resetUrl); + + await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct); + _logger.LogInformation("Password reset email sent to {Email}", toEmail); + } + + public async Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default) + { + var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}"; + var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl); + + await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct); + _logger.LogInformation("Verification email sent to {Email}", toEmail); + } + + public async Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default) + { + var dashboardUrl = $"{_settings.BaseUrl}/dashboard"; + var (subject, htmlBody, textBody) = EmailTemplates.Welcome(userName, dashboardUrl); + + await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct); + _logger.LogInformation("Welcome email sent to {Email}", toEmail); + } + + private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody, CancellationToken ct) + { + if (_settings.Smtp == null) + { + _logger.LogWarning("SMTP settings not configured. Email not sent to {Email}", toEmail); + return; + } + + try + { + using var message = new MailMessage + { + From = new MailAddress(_settings.FromEmail, _settings.FromName), + Subject = subject, + IsBodyHtml = true, + Body = htmlBody + }; + + message.To.Add(new MailAddress(toEmail)); + + // Add plain text alternative + var plainTextView = AlternateView.CreateAlternateViewFromString(textBody, null, "text/plain"); + var htmlView = AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html"); + message.AlternateViews.Add(plainTextView); + message.AlternateViews.Add(htmlView); + + using var client = new SmtpClient(_settings.Smtp.Host, _settings.Smtp.Port) + { + EnableSsl = _settings.Smtp.UseSsl, + DeliveryMethod = SmtpDeliveryMethod.Network + }; + + if (!string.IsNullOrEmpty(_settings.Smtp.Username)) + { + client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password); + } + + await client.SendMailAsync(message, ct); + _logger.LogDebug("Email sent successfully to {Email}", toEmail); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send email to {Email}", toEmail); + throw; + } + } +} diff --git a/src/api/Features/Email/Templates/EmailTemplates.cs b/src/api/Features/Email/Templates/EmailTemplates.cs new file mode 100644 index 0000000..365718a --- /dev/null +++ b/src/api/Features/Email/Templates/EmailTemplates.cs @@ -0,0 +1,221 @@ +namespace api.Features.Email.Templates; + +public static class EmailTemplates +{ + public static (string Subject, string HtmlBody, string TextBody) PasswordReset(string resetUrl) + { + var subject = "Reset your TrakQR password"; + + var htmlBody = $@" + + + + + + Reset Your Password + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ TQ +
+

Reset your password

+
+

We received a request to reset your password. Click the button below to choose a new password.

+
+ Reset Password +
+

This link will expire in 1 hour.

+

If you didn't request this, you can safely ignore this email.

+
+
+

© TrakQR. All rights reserved.

+
+ +"; + + var textBody = $@"Reset Your Password + +We received a request to reset your TrakQR password. + +Click the link below to reset your password: +{resetUrl} + +This link will expire in 1 hour. + +If you didn't request this, you can safely ignore this email. + +- The TrakQR Team"; + + return (subject, htmlBody, textBody); + } + + public static (string Subject, string HtmlBody, string TextBody) EmailVerification(string verifyUrl) + { + var subject = "Verify your TrakQR email"; + + var htmlBody = $@" + + + + + + Verify Your Email + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ TQ +
+

Verify your email

+
+

Thanks for signing up! Please verify your email address to get started with TrakQR.

+
+ Verify Email +
+

This link will expire in 24 hours.

+
+
+

© TrakQR. All rights reserved.

+
+ +"; + + var textBody = $@"Verify Your Email + +Thanks for signing up for TrakQR! + +Please verify your email address by clicking the link below: +{verifyUrl} + +This link will expire in 24 hours. + +- The TrakQR Team"; + + return (subject, htmlBody, textBody); + } + + public static (string Subject, string HtmlBody, string TextBody) Welcome(string userName, string dashboardUrl) + { + var subject = "Welcome to TrakQR!"; + + var htmlBody = $@" + + + + + + Welcome to TrakQR + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ TQ +
+

Welcome to TrakQR!

+
+

Hi {userName},

+

You're all set! Start creating short links and beautiful QR codes with powerful analytics.

+
+ Go to Dashboard +
+

Get started:

+
    +
  • Create your first short link
  • +
  • Design a custom QR code
  • +
  • Track clicks and scans in real-time
  • +
+
+
+

© TrakQR. All rights reserved.

+
+ +"; + + var textBody = $@"Welcome to TrakQR! + +Hi {userName}, + +You're all set! Start creating short links and beautiful QR codes with powerful analytics. + +Get started: +- Create your first short link +- Design a custom QR code +- Track clicks and scans in real-time + +Go to your dashboard: {dashboardUrl} + +- The TrakQR Team"; + + return (subject, htmlBody, textBody); + } +} diff --git a/src/api/Features/Events/Services/EventTrackingService.cs b/src/api/Features/Events/Services/EventTrackingService.cs index 282a82b..07dfc4b 100644 --- a/src/api/Features/Events/Services/EventTrackingService.cs +++ b/src/api/Features/Events/Services/EventTrackingService.cs @@ -13,7 +13,7 @@ public interface IEventTrackingService Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context); } -public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger logger) +public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpService geoIpService, ILogger logger) : IEventTrackingService { // Dedupe window - same visitor clicking same link within this window counts as one @@ -72,6 +72,7 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger _logger; + + public GeoIpService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + var dbPath = configuration["GeoIP:DatabasePath"]; + + if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) + { + try + { + _reader = new DatabaseReader(dbPath); + _logger.LogInformation("GeoIP database loaded from {Path}", dbPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath); + } + } + else + { + _logger.LogInformation("GeoIP database not configured or not found. Country detection disabled."); + } + } + + public string? GetCountryCode(string ipAddress) + { + if (_reader == null) + return null; + + try + { + // Handle localhost and private IPs + if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress)) + { + return null; + } + + if (!IPAddress.TryParse(ipAddress, out var ip)) + { + return null; + } + + if (_reader.TryCountry(ip, out var response)) + { + return response?.Country?.IsoCode; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to lookup country for IP {IP}", ipAddress); + } + + return null; + } + + private static bool IsPrivateIp(string ipAddress) + { + if (!IPAddress.TryParse(ipAddress, out var ip)) + return false; + + var bytes = ip.GetAddressBytes(); + + // Check for IPv4 private ranges + if (bytes.Length == 4) + { + // 10.0.0.0/8 + if (bytes[0] == 10) return true; + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) return true; + } + + return false; + } + + public void Dispose() + { + _reader?.Dispose(); + } +} diff --git a/src/api/Features/Links/Common/LinkDto.cs b/src/api/Features/Links/Common/LinkDto.cs new file mode 100644 index 0000000..51b8e67 --- /dev/null +++ b/src/api/Features/Links/Common/LinkDto.cs @@ -0,0 +1,12 @@ +namespace api.Features.Links.Endpoints; + +public class LinkDto +{ + public required Guid Id { get; set; } + public required string Slug { get; set; } + public required string DestinationUrl { get; set; } + public required string? Title { get; set; } + public required string Status { get; set; } + public int ClickCount { get; set; } + public DateTimeOffset CreatedAt { get; set; } +}; \ No newline at end of file diff --git a/src/api/Features/Links/Common/LinkResponses.cs b/src/api/Features/Links/Common/LinkResponses.cs index 7550dd2..194297a 100644 --- a/src/api/Features/Links/Common/LinkResponses.cs +++ b/src/api/Features/Links/Common/LinkResponses.cs @@ -12,7 +12,8 @@ public record LinkResponse( DateTime? ExpiresAt, bool HasPassword, DateTime CreatedAt, - DateTime UpdatedAt + DateTime UpdatedAt, + DateTime? DeletedAt = null ); public record LinkListResponse( diff --git a/src/api/Features/Links/Endpoints/BulkCreateLinksEndpoint.cs b/src/api/Features/Links/Endpoints/BulkCreateLinksEndpoint.cs new file mode 100644 index 0000000..7e97817 --- /dev/null +++ b/src/api/Features/Links/Endpoints/BulkCreateLinksEndpoint.cs @@ -0,0 +1,177 @@ +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; + +namespace api.Features.Links.Endpoints; + +public class BulkCreateLinksRequest +{ + public Guid WorkspaceId { get; set; } + public required List Links { get; set; } +} + +public class BulkLinkItem +{ + public required string DestinationUrl { get; set; } + public string? Title { get; set; } + public string? Slug { get; set; } +} + +public class BulkCreateLinksResponse +{ + public required List Created { get; set; } + public required List Errors { get; set; } +} + +public class BulkLinkError +{ + public int Index { get; set; } + public required string Url { get; set; } + public required string Error { get; set; } +} + +public class BulkCreateLinksEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Post("/workspaces/{WorkspaceId}/links/bulk"); + } + + public override async Task HandleAsync(BulkCreateLinksRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + // Verify workspace ownership + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (workspace is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + // Limit bulk creation to 100 links at a time + if (req.Links.Count > 100) + { + await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400, cancellation: ct); + return; + } + + var created = new List(); + var errors = new List(); + + // Check for plan limits + 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++) + { + var item = req.Links[i]; + + // Validate URL + if (!Uri.TryCreate(item.DestinationUrl, UriKind.Absolute, out var uri) || + (uri.Scheme != "http" && uri.Scheme != "https")) + { + errors.Add(new BulkLinkError + { + Index = i, + Url = item.DestinationUrl, + Error = "Invalid URL" + }); + continue; + } + + // Check plan limits + if (linkLimit.HasValue && currentLinkCount + created.Count >= linkLimit.Value) + { + errors.Add(new BulkLinkError + { + Index = i, + Url = item.DestinationUrl, + Error = "Plan link limit reached" + }); + continue; + } + + // Generate or validate slug + var slug = item.Slug; + if (string.IsNullOrWhiteSpace(slug)) + { + slug = GenerateSlug(); + } + else + { + // Check if slug is taken + var slugTaken = await db.ShortLinks.AnyAsync(l => l.Slug == slug, ct); + if (slugTaken) + { + errors.Add(new BulkLinkError + { + Index = i, + Url = item.DestinationUrl, + Error = $"Slug '{slug}' is already taken" + }); + continue; + } + } + + var link = new ShortLink + { + Id = Guid.NewGuid(), + WorkspaceId = req.WorkspaceId, + Slug = slug, + DestinationUrl = item.DestinationUrl, + Title = item.Title, + Status = ShortLinkStatus.Active, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + db.ShortLinks.Add(link); + + created.Add(new LinkDto + { + Id = link.Id, + Slug = link.Slug, + DestinationUrl = link.DestinationUrl, + Title = link?.Title, + Status = link.Status.ToString(), + ClickCount = 0, + CreatedAt = link.CreatedAt, + }); + } + + await db.SaveChangesAsync(ct); + + var response = new BulkCreateLinksResponse + { + Created = created, + Errors = errors + }; + + await HttpContext.Response.SendAsync(response, 201, cancellation: ct); + } + + private static string GenerateSlug() + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var random = new Random(); + return new string(Enumerable.Repeat(chars, 7).Select(s => s[random.Next(s.Length)]).ToArray()); + } + + private static int? GetPlanLinkLimit(WorkspacePlan? plan) + { + return plan switch + { + WorkspacePlan.Business => null, // Unlimited + WorkspacePlan.Pro => 10000, + _ => 100 // Free plan + }; + } +} diff --git a/src/api/Features/Links/Endpoints/CreateLinkEndpoint.cs b/src/api/Features/Links/Endpoints/CreateLinkEndpoint.cs index d69ed5c..83c26ce 100644 --- a/src/api/Features/Links/Endpoints/CreateLinkEndpoint.cs +++ b/src/api/Features/Links/Endpoints/CreateLinkEndpoint.cs @@ -2,6 +2,7 @@ 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; @@ -43,7 +44,7 @@ public class CreateLinkValidator : Validator } } -public class CreateLinkEndpoint(AppDbContext db) +public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits) : Endpoint { public override void Configure() @@ -65,6 +66,16 @@ public class CreateLinkEndpoint(AppDbContext db) return; } + // Check plan limits + if (!await planLimits.CanCreateLinkAsync(req.WorkspaceId, ct)) + { + await HttpContext.Response.SendAsync( + new MessageResponse("Link limit reached. Please upgrade your plan to create more links."), + 402, + cancellation: ct); + return; + } + // Verify project belongs to workspace if specified if (req.ProjectId.HasValue) { diff --git a/src/api/Features/Links/Endpoints/DeleteLinkEndpoint.cs b/src/api/Features/Links/Endpoints/DeleteLinkEndpoint.cs index 564cf41..1570c54 100644 --- a/src/api/Features/Links/Endpoints/DeleteLinkEndpoint.cs +++ b/src/api/Features/Links/Endpoints/DeleteLinkEndpoint.cs @@ -26,7 +26,11 @@ public class DeleteLinkEndpoint(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 && + l.DeletedAt == null, ct); if (link is null) { @@ -34,7 +38,8 @@ public class DeleteLinkEndpoint(AppDbContext db) return; } - db.ShortLinks.Remove(link); + // Soft delete + link.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct); diff --git a/src/api/Features/Links/Endpoints/GetLinkEndpoint.cs b/src/api/Features/Links/Endpoints/GetLinkEndpoint.cs index bbb079b..c6a540b 100644 --- a/src/api/Features/Links/Endpoints/GetLinkEndpoint.cs +++ b/src/api/Features/Links/Endpoints/GetLinkEndpoint.cs @@ -26,7 +26,7 @@ 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) + .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, @@ -39,7 +39,8 @@ public class GetLinkEndpoint(AppDbContext db) l.ExpiresAt, l.PasswordHash != null, l.CreatedAt, - l.UpdatedAt + l.UpdatedAt, + l.DeletedAt )) .FirstOrDefaultAsync(ct); diff --git a/src/api/Features/Links/Endpoints/ListLinksEndpoint.cs b/src/api/Features/Links/Endpoints/ListLinksEndpoint.cs index 4176780..37a26ed 100644 --- a/src/api/Features/Links/Endpoints/ListLinksEndpoint.cs +++ b/src/api/Features/Links/Endpoints/ListLinksEndpoint.cs @@ -12,6 +12,7 @@ public class ListLinksRequest public Guid WorkspaceId { get; set; } public Guid? ProjectId { get; set; } public string? Status { get; set; } + public bool IncludeDeleted { get; set; } = false; } public class ListLinksEndpoint(AppDbContext db) @@ -39,6 +40,12 @@ public class ListLinksEndpoint(AppDbContext db) var query = db.ShortLinks .Where(l => l.WorkspaceId == req.WorkspaceId); + // Filter by deleted status (exclude soft-deleted by default) + if (!req.IncludeDeleted) + { + query = query.Where(l => l.DeletedAt == null); + } + // Filter by project if specified if (req.ProjectId.HasValue) { @@ -65,7 +72,8 @@ public class ListLinksEndpoint(AppDbContext db) l.ExpiresAt, l.PasswordHash != null, l.CreatedAt, - l.UpdatedAt + l.UpdatedAt, + l.DeletedAt )) .ToListAsync(ct); diff --git a/src/api/Features/Links/Endpoints/RestoreLinkEndpoint.cs b/src/api/Features/Links/Endpoints/RestoreLinkEndpoint.cs new file mode 100644 index 0000000..f3cab0e --- /dev/null +++ b/src/api/Features/Links/Endpoints/RestoreLinkEndpoint.cs @@ -0,0 +1,65 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Links.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Links.Endpoints; + +public class RestoreLinkRequest +{ + public Guid WorkspaceId { get; set; } + public Guid Id { get; set; } +} + +public class RestoreLinkEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Post("/workspaces/{WorkspaceId}/links/{Id}/restore"); + } + + public override async Task HandleAsync(RestoreLinkRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var link = await db.ShortLinks + .Include(l => l.Workspace) + .FirstOrDefaultAsync(l => + l.Id == req.Id && + l.WorkspaceId == req.WorkspaceId && + l.Workspace.OwnerUserId == userId && + l.DeletedAt != null, ct); + + if (link is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Deleted link not found"), 404, cancellation: ct); + return; + } + + // Restore the link + link.DeletedAt = null; + link.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + var response = new LinkResponse( + link.Id, + link.WorkspaceId, + link.ProjectId, + link.DomainId, + link.Slug, + link.DestinationUrl, + link.Title, + link.Status.ToString(), + link.ExpiresAt, + link.PasswordHash != null, + link.CreatedAt, + link.UpdatedAt, + link.DeletedAt + ); + + await HttpContext.Response.SendAsync(response, 200, cancellation: ct); + } +} diff --git a/src/api/Features/Plans/Endpoints/GetUsageEndpoint.cs b/src/api/Features/Plans/Endpoints/GetUsageEndpoint.cs new file mode 100644 index 0000000..01bf1da --- /dev/null +++ b/src/api/Features/Plans/Endpoints/GetUsageEndpoint.cs @@ -0,0 +1,94 @@ +using System.Security.Claims; +using api.Features.Plans.Services; +using FastEndpoints; + +namespace api.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 +{ + 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( + Workspaces: 1, + Links: wsUsage.Links, + QRCodes: wsUsage.QRCodes, + Domains: wsUsage.Domains, + EventsThisMonth: wsUsage.EventsThisMonth, + Plan: wsUsage.Plan.ToString(), + Limits: new LimitsResponse( + MaxWorkspaces: wsUsage.Limits.MaxWorkspaces, + MaxLinks: wsUsage.Limits.MaxLinksPerWorkspace, + MaxQRCodes: wsUsage.Limits.MaxQRCodesPerWorkspace, + MaxDomains: wsUsage.Limits.MaxDomainsPerWorkspace, + MaxEventsPerMonth: wsUsage.Limits.MaxEventsPerMonth, + HasCustomDomains: wsUsage.Limits.HasCustomDomains, + HasPasswordProtection: 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( + Workspaces: usage.TotalWorkspaces, + Links: usage.TotalLinks, + QRCodes: usage.TotalQRCodes, + Domains: usage.TotalDomains, + EventsThisMonth: usage.EventsThisMonth, + Plan: usage.HighestPlan.ToString(), + Limits: new LimitsResponse( + MaxWorkspaces: limits.MaxWorkspaces, + MaxLinks: limits.MaxLinksPerWorkspace, + MaxQRCodes: limits.MaxQRCodesPerWorkspace, + MaxDomains: limits.MaxDomainsPerWorkspace, + MaxEventsPerMonth: limits.MaxEventsPerMonth, + HasCustomDomains: limits.HasCustomDomains, + HasPasswordProtection: limits.HasPasswordProtection + ) + ); + + await HttpContext.Response.SendAsync(response, cancellation: ct); + } + } +} diff --git a/src/api/Features/Plans/Services/PlanLimitsService.cs b/src/api/Features/Plans/Services/PlanLimitsService.cs new file mode 100644 index 0000000..052dcd2 --- /dev/null +++ b/src/api/Features/Plans/Services/PlanLimitsService.cs @@ -0,0 +1,190 @@ +using api.Data; +using api.Models; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Plans.Services; + +public interface IPlanLimitsService +{ + PlanLimits GetLimits(WorkspacePlan plan); + Task GetUsageAsync(Guid userId, CancellationToken ct = default); + Task GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default); + Task CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default); + Task CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default); + Task CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default); + Task CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default); + Task CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default); +} + +public record PlanLimits( + int MaxWorkspaces, + int MaxLinksPerWorkspace, + int MaxQRCodesPerWorkspace, + int MaxDomainsPerWorkspace, + int MaxEventsPerMonth, + bool HasCustomDomains, + bool HasPasswordProtection, + bool HasAnalytics +); + +public record UsageStats( + int TotalWorkspaces, + int TotalLinks, + int TotalQRCodes, + int TotalDomains, + int EventsThisMonth, + WorkspacePlan HighestPlan +); + +public record WorkspaceUsageStats( + Guid WorkspaceId, + WorkspacePlan Plan, + int Links, + int QRCodes, + int Domains, + int EventsThisMonth, + PlanLimits Limits +); + +public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsService +{ + private static readonly Dictionary PlanConfigs = new() + { + [WorkspacePlan.Free] = new PlanLimits( + MaxWorkspaces: 1, + MaxLinksPerWorkspace: 50, + MaxQRCodesPerWorkspace: 25, + MaxDomainsPerWorkspace: 0, + MaxEventsPerMonth: 10_000, + HasCustomDomains: false, + HasPasswordProtection: false, + HasAnalytics: true + ), + [WorkspacePlan.Pro] = new PlanLimits( + MaxWorkspaces: 5, + MaxLinksPerWorkspace: 5_000, + MaxQRCodesPerWorkspace: 1_000, + MaxDomainsPerWorkspace: 3, + MaxEventsPerMonth: 100_000, + HasCustomDomains: true, + HasPasswordProtection: true, + HasAnalytics: 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 + ) + }; + + public PlanLimits GetLimits(WorkspacePlan plan) => PlanConfigs[plan]; + + public async Task GetUsageAsync(Guid userId, CancellationToken ct = default) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var workspaces = await db.Workspaces + .Where(w => w.OwnerUserId == userId) + .Select(w => new { w.Id, w.Plan }) + .ToListAsync(ct); + + var workspaceIds = workspaces.Select(w => w.Id).ToList(); + + var totalLinks = await db.ShortLinks + .CountAsync(l => workspaceIds.Contains(l.WorkspaceId), ct); + + var totalQRCodes = await db.QrCodeDesigns + .CountAsync(q => workspaceIds.Contains(q.WorkspaceId), ct); + + var totalDomains = await db.Domains + .CountAsync(d => workspaceIds.Contains(d.WorkspaceId), ct); + + var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var eventsThisMonth = await db.Events + .CountAsync(e => workspaceIds.Contains(e.WorkspaceId) && e.Timestamp >= monthStart, ct); + + var highestPlan = workspaces.Any() + ? workspaces.Max(w => w.Plan) + : WorkspacePlan.Free; + + return new UsageStats( + TotalWorkspaces: workspaces.Count, + TotalLinks: totalLinks, + TotalQRCodes: totalQRCodes, + TotalDomains: totalDomains, + EventsThisMonth: eventsThisMonth, + HighestPlan: highestPlan + ); + } + + public async Task GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var workspace = await db.Workspaces + .Where(w => w.Id == workspaceId) + .Select(w => new { w.Id, w.Plan }) + .FirstOrDefaultAsync(ct); + + if (workspace == null) + throw new KeyNotFoundException("Workspace not found"); + + var links = await db.ShortLinks.CountAsync(l => l.WorkspaceId == workspaceId, ct); + var qrCodes = await db.QrCodeDesigns.CountAsync(q => q.WorkspaceId == workspaceId, ct); + var domains = await db.Domains.CountAsync(d => d.WorkspaceId == workspaceId, ct); + + var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var eventsThisMonth = await db.Events + .CountAsync(e => e.WorkspaceId == workspaceId && e.Timestamp >= monthStart, ct); + + var limits = GetLimits(workspace.Plan); + + return new WorkspaceUsageStats( + WorkspaceId: workspaceId, + Plan: workspace.Plan, + Links: links, + QRCodes: qrCodes, + Domains: domains, + EventsThisMonth: eventsThisMonth, + Limits: limits + ); + } + + public async Task CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default) + { + var usage = await GetUsageAsync(userId, ct); + var limits = GetLimits(usage.HighestPlan); + return usage.TotalWorkspaces < limits.MaxWorkspaces; + } + + public async Task CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default) + { + var usage = await GetWorkspaceUsageAsync(workspaceId, ct); + return usage.Links < usage.Limits.MaxLinksPerWorkspace; + } + + public async Task CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default) + { + var usage = await GetWorkspaceUsageAsync(workspaceId, ct); + return usage.QRCodes < usage.Limits.MaxQRCodesPerWorkspace; + } + + public async Task CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default) + { + var usage = await GetWorkspaceUsageAsync(workspaceId, ct); + return usage.Domains < usage.Limits.MaxDomainsPerWorkspace; + } + + public async Task CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default) + { + var usage = await GetWorkspaceUsageAsync(workspaceId, ct); + return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth; + } +} diff --git a/src/api/Features/Projects/Common/ProjectResponses.cs b/src/api/Features/Projects/Common/ProjectResponses.cs index 93985e5..c7e2338 100644 --- a/src/api/Features/Projects/Common/ProjectResponses.cs +++ b/src/api/Features/Projects/Common/ProjectResponses.cs @@ -4,6 +4,9 @@ public record ProjectResponse( Guid Id, Guid WorkspaceId, string Name, + string? Description, + int LinkCount, + int QRCodeCount, DateTime CreatedAt ); diff --git a/src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs b/src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs index 474a7a8..37bb6b9 100644 --- a/src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs +++ b/src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs @@ -13,6 +13,7 @@ public class CreateProjectRequest { public Guid WorkspaceId { get; set; } public string Name { get; set; } = string.Empty; + public string? Description { get; set; } } public class CreateProjectValidator : Validator @@ -52,6 +53,7 @@ public class CreateProjectEndpoint(AppDbContext db) Id = Guid.NewGuid(), WorkspaceId = req.WorkspaceId, Name = req.Name, + Description = req.Description, CreatedAt = DateTime.UtcNow }; @@ -62,6 +64,9 @@ public class CreateProjectEndpoint(AppDbContext db) project.Id, project.WorkspaceId, project.Name, + project.Description, + 0, // LinkCount - new project has no links + 0, // QRCodeCount - new project has no QR codes project.CreatedAt ); diff --git a/src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs b/src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs index c238a98..eae4e29 100644 --- a/src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs +++ b/src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs @@ -31,6 +31,9 @@ public class GetProjectEndpoint(AppDbContext db) p.Id, p.WorkspaceId, p.Name, + p.Description, + p.ShortLinks.Count(l => l.DeletedAt == null), + p.QRCodeDesigns.Count, p.CreatedAt )) .FirstOrDefaultAsync(ct); diff --git a/src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs b/src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs index 70f1ba9..a3f225a 100644 --- a/src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs +++ b/src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs @@ -41,6 +41,9 @@ public class ListProjectsEndpoint(AppDbContext db) p.Id, p.WorkspaceId, p.Name, + p.Description, + p.ShortLinks.Count(l => l.DeletedAt == null), + p.QRCodeDesigns.Count, p.CreatedAt )) .ToListAsync(ct); diff --git a/src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs b/src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs index 7a1a09b..3c9efa8 100644 --- a/src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs +++ b/src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs @@ -13,6 +13,7 @@ public class UpdateProjectRequest public Guid WorkspaceId { get; set; } public Guid Id { get; set; } public string Name { get; set; } = string.Empty; + public string? Description { get; set; } } public class UpdateProjectValidator : Validator @@ -39,6 +40,8 @@ public class UpdateProjectEndpoint(AppDbContext db) var project = await db.Projects .Include(p => p.Workspace) + .Include(p => p.ShortLinks) + .Include(p => p.QRCodeDesigns) .FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct); if (project is null) @@ -48,12 +51,16 @@ public class UpdateProjectEndpoint(AppDbContext db) } project.Name = req.Name; + project.Description = req.Description; await db.SaveChangesAsync(ct); var response = new ProjectResponse( project.Id, project.WorkspaceId, project.Name, + project.Description, + project.ShortLinks.Count(l => l.DeletedAt == null), + project.QRCodeDesigns.Count, project.CreatedAt ); diff --git a/src/api/Features/QRCodes/Common/QRCodeModels.cs b/src/api/Features/QRCodes/Common/QRCodeModels.cs index 3ad5389..45be832 100644 --- a/src/api/Features/QRCodes/Common/QRCodeModels.cs +++ b/src/api/Features/QRCodes/Common/QRCodeModels.cs @@ -35,8 +35,10 @@ public record QRCodeResponse( Guid? ProjectId, Guid? ShortLinkId, string? ShortLinkSlug, + string Name, QRCodeStyle Style, Guid? LogoAssetId, + string? LogoUrl, DateTime CreatedAt, DateTime UpdatedAt ); diff --git a/src/api/Features/QRCodes/Endpoints/CreateQRCodeEndpoint.cs b/src/api/Features/QRCodes/Endpoints/CreateQRCodeEndpoint.cs index a5d32ee..113dc5f 100644 --- a/src/api/Features/QRCodes/Endpoints/CreateQRCodeEndpoint.cs +++ b/src/api/Features/QRCodes/Endpoints/CreateQRCodeEndpoint.cs @@ -2,6 +2,7 @@ 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; @@ -15,6 +16,8 @@ public class CreateQRCodeRequest public Guid WorkspaceId { get; set; } public Guid? ProjectId { get; set; } public Guid? ShortLinkId { get; set; } + public Guid? LogoAssetId { get; set; } + public string? Name { get; set; } public QRCodeStyle? Style { get; set; } } @@ -27,7 +30,7 @@ public class CreateQRCodeValidator : Validator } } -public class CreateQRCodeEndpoint(AppDbContext db) +public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits) : Endpoint { public override void Configure() @@ -49,6 +52,16 @@ public class CreateQRCodeEndpoint(AppDbContext db) return; } + // Check plan limits + if (!await planLimits.CanCreateQRCodeAsync(req.WorkspaceId, ct)) + { + await HttpContext.Response.SendAsync( + new MessageResponse("QR code limit reached. Please upgrade your plan to create more QR codes."), + 402, + cancellation: ct); + return; + } + // Verify short link belongs to workspace string? linkSlug = null; if (req.ShortLinkId.HasValue) @@ -79,7 +92,26 @@ public class CreateQRCodeEndpoint(AppDbContext db) } } + // Verify logo asset belongs to workspace if specified + string? logoUrl = null; + if (req.LogoAssetId.HasValue) + { + var asset = await db.Assets + .Where(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId) + .Select(a => new { a.StorageKey }) + .FirstOrDefaultAsync(ct); + + if (asset is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct); + return; + } + + logoUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/assets/{asset.StorageKey}"; + } + var style = req.Style ?? new QRCodeStyle(); + var name = req.Name ?? $"QR Code {DateTime.UtcNow:yyyy-MM-dd}"; var now = DateTime.UtcNow; var qrCode = new QRCodeDesign @@ -88,8 +120,9 @@ public class CreateQRCodeEndpoint(AppDbContext db) WorkspaceId = req.WorkspaceId, ProjectId = req.ProjectId, ShortLinkId = req.ShortLinkId, + Name = name, StyleJson = JsonSerializer.Serialize(style), - LogoAssetId = null, + LogoAssetId = req.LogoAssetId, CreatedAt = now, UpdatedAt = now }; @@ -103,8 +136,10 @@ public class CreateQRCodeEndpoint(AppDbContext db) qrCode.ProjectId, qrCode.ShortLinkId, linkSlug, + qrCode.Name, style, qrCode.LogoAssetId, + logoUrl, qrCode.CreatedAt, qrCode.UpdatedAt ); diff --git a/src/api/Features/QRCodes/Endpoints/ExportQRCodeEndpoint.cs b/src/api/Features/QRCodes/Endpoints/ExportQRCodeEndpoint.cs index a8bbb18..4f2e753 100644 --- a/src/api/Features/QRCodes/Endpoints/ExportQRCodeEndpoint.cs +++ b/src/api/Features/QRCodes/Endpoints/ExportQRCodeEndpoint.cs @@ -1,6 +1,7 @@ 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; @@ -17,7 +18,7 @@ public class ExportQRCodeRequest public int? Size { get; set; } } -public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator) +public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage) : Endpoint { public override void Configure() @@ -31,6 +32,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen var qrCode = await db.QrCodeDesigns .Include(q => q.ShortLink) + .Include(q => q.LogoAsset) .Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId) .FirstOrDefaultAsync(ct); @@ -50,25 +52,44 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen var format = (req.Format ?? "png").ToLowerInvariant(); var size = req.Size ?? 512; - // Build the short link URL + // Build the short link URL with QR tracking param var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; - var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}"; + var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}?qr={qrCode.Id}"; var filename = $"qrcode-{qrCode.ShortLink.Slug}"; - if (format == "svg") + // Load logo if available + Stream? logoStream = null; + if (qrCode.LogoAsset != null) { - var svg = qrGenerator.GenerateSvg(linkUrl, style, size); - HttpContext.Response.ContentType = "image/svg+xml"; - HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\""; - await HttpContext.Response.WriteAsync(svg, ct); + var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey); + if (logoResult.HasValue) + { + logoStream = logoResult.Value.Stream; + } } - else + + try { - var png = qrGenerator.GeneratePng(linkUrl, style, size); - HttpContext.Response.ContentType = "image/png"; - HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\""; - await HttpContext.Response.Body.WriteAsync(png, ct); + if (format == "svg") + { + // SVG doesn't support logo overlay currently + var svg = qrGenerator.GenerateSvg(linkUrl, style, size); + HttpContext.Response.ContentType = "image/svg+xml"; + HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\""; + await HttpContext.Response.WriteAsync(svg, ct); + } + else + { + var png = qrGenerator.GeneratePng(linkUrl, style, size, logoStream); + HttpContext.Response.ContentType = "image/png"; + HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\""; + await HttpContext.Response.Body.WriteAsync(png, ct); + } + } + finally + { + logoStream?.Dispose(); } } } diff --git a/src/api/Features/QRCodes/Endpoints/GetQRCodeAnalyticsEndpoint.cs b/src/api/Features/QRCodes/Endpoints/GetQRCodeAnalyticsEndpoint.cs new file mode 100644 index 0000000..0ca0a09 --- /dev/null +++ b/src/api/Features/QRCodes/Endpoints/GetQRCodeAnalyticsEndpoint.cs @@ -0,0 +1,138 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Models; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.QRCodes.Endpoints; + +public class GetQRCodeAnalyticsRequest +{ + public Guid WorkspaceId { get; set; } + public Guid Id { get; set; } + public string Period { get; set; } = "7d"; +} + +public record QRCodeAnalyticsSummary( + int TotalScans, + int UniqueVisitors +); + +public record QRCodeTimeSeriesPoint( + string Date, + int Scans +); + +public record QRCodeAnalyticsResponse( + Guid QRCodeId, + string Name, + string? LinkSlug, + QRCodeAnalyticsSummary Summary, + List TimeSeries, + Dictionary DeviceBreakdown, + Dictionary ReferrerBreakdown, + Dictionary CountryBreakdown +); + +public class GetQRCodeAnalyticsEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/analytics"); + } + + public override async Task HandleAsync(GetQRCodeAnalyticsRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var qrCode = await db.QrCodeDesigns + .Include(q => q.ShortLink) + .Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId) + .FirstOrDefaultAsync(ct); + + if (qrCode == null) + { + await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct); + return; + } + + var startDate = GetStartDate(req.Period); + + var events = await db.Events + .Where(e => e.QRCodeId == req.Id && e.Type == EventType.Scan && e.Timestamp >= startDate) + .ToListAsync(ct); + + var summary = new QRCodeAnalyticsSummary( + TotalScans: events.Count, + UniqueVisitors: 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() + )) + .ToList(); + + var deviceBreakdown = events + .Where(e => !string.IsNullOrEmpty(e.DeviceType)) + .GroupBy(e => e.DeviceType!) + .ToDictionary(g => g.Key, g => g.Count()); + + var referrerBreakdown = events + .Where(e => !string.IsNullOrEmpty(e.Referrer)) + .GroupBy(e => GetReferrerDomain(e.Referrer!)) + .OrderByDescending(g => g.Count()) + .Take(10) + .ToDictionary(g => g.Key, g => g.Count()); + + var countryBreakdown = events + .Where(e => !string.IsNullOrEmpty(e.CountryCode)) + .GroupBy(e => e.CountryCode!) + .OrderByDescending(g => g.Count()) + .Take(10) + .ToDictionary(g => g.Key, g => g.Count()); + + var response = new QRCodeAnalyticsResponse( + QRCodeId: qrCode.Id, + Name: qrCode.Name, + LinkSlug: qrCode.ShortLink?.Slug, + Summary: summary, + TimeSeries: timeSeries, + DeviceBreakdown: deviceBreakdown, + ReferrerBreakdown: referrerBreakdown, + CountryBreakdown: countryBreakdown + ); + + await HttpContext.Response.SendAsync(response, cancellation: ct); + } + + private static DateTime GetStartDate(string period) + { + return period switch + { + "24h" => DateTime.UtcNow.AddHours(-24), + "7d" => DateTime.UtcNow.AddDays(-7), + "30d" => DateTime.UtcNow.AddDays(-30), + "all" => DateTime.MinValue, + _ => DateTime.UtcNow.AddDays(-7) + }; + } + + private static string GetReferrerDomain(string referrer) + { + try + { + var uri = new Uri(referrer); + return uri.Host; + } + catch + { + return referrer; + } + } +} diff --git a/src/api/Features/QRCodes/Endpoints/GetQRCodeEndpoint.cs b/src/api/Features/QRCodes/Endpoints/GetQRCodeEndpoint.cs index 2efe01a..39a9947 100644 --- a/src/api/Features/QRCodes/Endpoints/GetQRCodeEndpoint.cs +++ b/src/api/Features/QRCodes/Endpoints/GetQRCodeEndpoint.cs @@ -28,6 +28,7 @@ public class GetQRCodeEndpoint(AppDbContext db) var qrCode = await db.QrCodeDesigns .Include(q => q.ShortLink) + .Include(q => q.LogoAsset) .Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId) .FirstOrDefaultAsync(ct); @@ -38,6 +39,7 @@ public class GetQRCodeEndpoint(AppDbContext db) } var style = JsonSerializer.Deserialize(qrCode.StyleJson) ?? new QRCodeStyle(); + var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; var response = new QRCodeResponse( qrCode.Id, @@ -45,8 +47,10 @@ public class GetQRCodeEndpoint(AppDbContext db) qrCode.ProjectId, qrCode.ShortLinkId, qrCode.ShortLink?.Slug, + qrCode.Name, style, qrCode.LogoAssetId, + qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null, qrCode.CreatedAt, qrCode.UpdatedAt ); diff --git a/src/api/Features/QRCodes/Endpoints/ListQRCodesEndpoint.cs b/src/api/Features/QRCodes/Endpoints/ListQRCodesEndpoint.cs index 3dee20e..6550687 100644 --- a/src/api/Features/QRCodes/Endpoints/ListQRCodesEndpoint.cs +++ b/src/api/Features/QRCodes/Endpoints/ListQRCodesEndpoint.cs @@ -52,9 +52,12 @@ public class ListQRCodesEndpoint(AppDbContext db) var qrCodes = await query .Include(q => q.ShortLink) + .Include(q => q.LogoAsset) .OrderByDescending(q => q.CreatedAt) .ToListAsync(ct); + var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; + var response = new QRCodeListResponse( qrCodes.Select(q => new QRCodeResponse( q.Id, @@ -62,8 +65,10 @@ public class ListQRCodesEndpoint(AppDbContext db) q.ProjectId, q.ShortLinkId, q.ShortLink?.Slug, + q.Name, JsonSerializer.Deserialize(q.StyleJson) ?? new QRCodeStyle(), q.LogoAssetId, + q.LogoAsset != null ? $"{baseUrl}/assets/{q.LogoAsset.StorageKey}" : null, q.CreatedAt, q.UpdatedAt )) diff --git a/src/api/Features/QRCodes/Endpoints/PreviewQRCodeEndpoint.cs b/src/api/Features/QRCodes/Endpoints/PreviewQRCodeEndpoint.cs index f438e6c..bb2ef42 100644 --- a/src/api/Features/QRCodes/Endpoints/PreviewQRCodeEndpoint.cs +++ b/src/api/Features/QRCodes/Endpoints/PreviewQRCodeEndpoint.cs @@ -1,6 +1,7 @@ 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; @@ -16,7 +17,7 @@ public class PreviewQRCodeRequest public int? Size { get; set; } } -public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator) +public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage) : Endpoint { public override void Configure() @@ -30,6 +31,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe var qrCode = await db.QrCodeDesigns .Include(q => q.ShortLink) + .Include(q => q.LogoAsset) .Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId) .FirstOrDefaultAsync(ct); @@ -53,15 +55,33 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}"; - var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size); + // Load logo if available + Stream? logoStream = null; + if (qrCode.LogoAsset != null) + { + var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey); + if (logoResult.HasValue) + { + logoStream = logoResult.Value.Stream; + } + } - var response = new QRCodePreviewResponse( - DataUrl: dataUrl, - Format: "png", - Width: size, - Height: size - ); + try + { + var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream); - await HttpContext.Response.SendAsync(response, 200, cancellation: ct); + var response = new QRCodePreviewResponse( + DataUrl: dataUrl, + Format: "png", + Width: size, + Height: size + ); + + await HttpContext.Response.SendAsync(response, 200, cancellation: ct); + } + finally + { + logoStream?.Dispose(); + } } } diff --git a/src/api/Features/QRCodes/Endpoints/UpdateQRCodeEndpoint.cs b/src/api/Features/QRCodes/Endpoints/UpdateQRCodeEndpoint.cs index 5b58c19..9419c6f 100644 --- a/src/api/Features/QRCodes/Endpoints/UpdateQRCodeEndpoint.cs +++ b/src/api/Features/QRCodes/Endpoints/UpdateQRCodeEndpoint.cs @@ -12,8 +12,11 @@ public class UpdateQRCodeRequest { public Guid WorkspaceId { get; set; } public Guid Id { get; set; } + public string? Name { get; set; } public Guid? ProjectId { get; set; } public bool? RemoveProject { get; set; } + public Guid? LogoAssetId { get; set; } + public bool? RemoveLogo { get; set; } public QRCodeStyle? Style { get; set; } } @@ -32,6 +35,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db) var qrCode = await db.QrCodeDesigns .Include(q => q.Workspace) .Include(q => q.ShortLink) + .Include(q => q.LogoAsset) .FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct); if (qrCode is null) @@ -58,6 +62,33 @@ public class UpdateQRCodeEndpoint(AppDbContext db) qrCode.ProjectId = null; } + // Update name if provided + if (!string.IsNullOrWhiteSpace(req.Name)) + { + qrCode.Name = req.Name; + } + + // Handle logo asset update + if (req.LogoAssetId.HasValue) + { + var assetExists = await db.Assets + .AnyAsync(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId, ct); + + if (!assetExists) + { + await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct); + return; + } + qrCode.LogoAssetId = req.LogoAssetId.Value; + // Reload the asset for the response + qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct); + } + else if (req.RemoveLogo == true) + { + qrCode.LogoAssetId = null; + qrCode.LogoAsset = null; + } + if (req.Style != null) { qrCode.StyleJson = JsonSerializer.Serialize(req.Style); @@ -67,6 +98,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db) await db.SaveChangesAsync(ct); var style = JsonSerializer.Deserialize(qrCode.StyleJson) ?? new QRCodeStyle(); + var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; var response = new QRCodeResponse( qrCode.Id, @@ -74,8 +106,10 @@ public class UpdateQRCodeEndpoint(AppDbContext db) qrCode.ProjectId, qrCode.ShortLinkId, qrCode.ShortLink?.Slug, + qrCode.Name, style, qrCode.LogoAssetId, + qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null, qrCode.CreatedAt, qrCode.UpdatedAt ); diff --git a/src/api/Features/QRCodes/Services/QRCodeGeneratorService.cs b/src/api/Features/QRCodes/Services/QRCodeGeneratorService.cs index d490bc4..206ce69 100644 --- a/src/api/Features/QRCodes/Services/QRCodeGeneratorService.cs +++ b/src/api/Features/QRCodes/Services/QRCodeGeneratorService.cs @@ -1,34 +1,129 @@ -using System.Drawing; using api.Features.QRCodes.Common; using QRCoder; +using SkiaSharp; namespace api.Features.QRCodes.Services; -public interface IQRCodeGeneratorService +public interface IQrCodeGeneratorService { - byte[] GeneratePng(string content, QRCodeStyle style, int size = 512); + byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null); string GenerateSvg(string content, QRCodeStyle style, int size = 512); - string GenerateDataUrl(string content, QRCodeStyle style, int size = 256); + string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null); } -public class QRCodeGeneratorService : IQRCodeGeneratorService +public class QrCodeGeneratorService : IQrCodeGeneratorService { - public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512) + public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null) { using var qrGenerator = new QRCodeGenerator(); var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel); using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel); - using var qrCode = new PngByteQRCode(qrCodeData); + var moduleMatrix = qrCodeData.ModuleMatrix; + var moduleCount = moduleMatrix.Count; - var foreground = ParseColor(style.ForegroundColor); - var background = ParseColor(style.BackgroundColor); + // Calculate pixels per module based on desired size (accounting for quiet zone) + var totalModules = moduleCount + (style.QuietZone * 2); + var pixelsPerModule = Math.Max(4, size / totalModules); + var actualSize = totalModules * pixelsPerModule; - // Calculate pixels per module based on desired size - var moduleCount = qrCodeData.ModuleMatrix.Count; - var pixelsPerModule = Math.Max(1, size / moduleCount); + // Create bitmap with SkiaSharp for custom shapes + var foregroundColor = ParseSkColor(style.ForegroundColor); + var backgroundColor = ParseSkColor(style.BackgroundColor); - return qrCode.GetGraphic(pixelsPerModule, foreground, background, drawQuietZones: style.QuietZone > 0); + using var surface = SKSurface.Create(new SKImageInfo(actualSize, actualSize)); + var canvas = surface.Canvas; + + // Draw background + canvas.Clear(backgroundColor); + + // Draw QR modules with custom shapes + var modulePaint = new SKPaint + { + Color = foregroundColor, + IsAntialias = true, + Style = SKPaintStyle.Fill + }; + + var quietZoneOffset = style.QuietZone * pixelsPerModule; + + for (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); + + // Check if this is part of a finder pattern (eyes) + var isEye = IsFinderPattern(x, y, moduleCount); + + if (isEye) + { + DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape); + } + else + { + DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape); + } + } + } + } + + // Encode to PNG + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + var qrBytes = data.ToArray(); + + // If no logo, return the QR code as-is + if (logoStream == null) + { + return qrBytes; + } + + // Overlay logo on QR code + return OverlayLogo(qrBytes, logoStream, actualSize); + } + + private static bool IsFinderPattern(int x, int y, int moduleCount) + { + // Top-left finder pattern: 0-6, 0-6 + if (x <= 6 && y <= 6) return true; + // Top-right finder pattern: moduleCount-7 to moduleCount-1, 0-6 + if (x >= moduleCount - 7 && y <= 6) return true; + // Bottom-left finder pattern: 0-6, moduleCount-7 to moduleCount-1 + if (x <= 6 && y >= moduleCount - 7) return true; + return false; + } + + private static void DrawModule(SKCanvas canvas, float x, float y, float size, SKPaint paint, string shape) + { + var padding = size * 0.1f; // 10% padding between modules + var moduleSize = size - padding; + + switch (shape.ToLowerInvariant()) + { + case "circle": + case "dots": + var radius = moduleSize / 2; + canvas.DrawCircle(x + size / 2, y + size / 2, radius, paint); + break; + + case "rounded": + var cornerRadius = moduleSize * 0.3f; + var rect = new SKRoundRect( + new SKRect(x + padding / 2, y + padding / 2, x + size - padding / 2, y + size - padding / 2), + cornerRadius + ); + canvas.DrawRoundRect(rect, paint); + break; + + case "square": + default: + canvas.DrawRect(x + padding / 2, y + padding / 2, moduleSize, moduleSize, paint); + break; + } } public string GenerateSvg(string content, QRCodeStyle style, int size = 512) @@ -37,30 +132,120 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel); using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel); - using var qrCode = new SvgQRCode(qrCodeData); + 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; - // Calculate pixels per module - var moduleCount = qrCodeData.ModuleMatrix.Count; - var pixelsPerModule = Math.Max(1, size / moduleCount); + var svg = new System.Text.StringBuilder(); + svg.AppendLine($""); + svg.AppendLine($" "); - return qrCode.GetGraphic( - pixelsPerModule, - foreground, - background, - drawQuietZones: style.QuietZone > 0 - ); + 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($" "); + break; + + case "rounded": + var cornerRadius = moduleSize * 0.3f; + svg.AppendLine($" "); + break; + + case "square": + default: + svg.AppendLine($" "); + break; + } + } + } + } + + svg.AppendLine(""); + return svg.ToString(); } - public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256) + public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null) { - var pngBytes = GeneratePng(content, style, size); + 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; + } + + // Logo should be about 20% of QR code size + var logoSize = (int)(qrSize * 0.2); + var logoX = (qrBitmap.Width - logoSize) / 2; + var logoY = (qrBitmap.Height - logoSize) / 2; + + // Create a new surface to draw on + using var surface = SKSurface.Create(new SKImageInfo(qrBitmap.Width, qrBitmap.Height)); + var canvas = surface.Canvas; + + // Draw QR code + canvas.DrawBitmap(qrBitmap, 0, 0); + + // Draw white background circle for logo + var circlePaint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true, + Style = SKPaintStyle.Fill + }; + var circleRadius = logoSize * 0.6f; + canvas.DrawCircle(qrBitmap.Width / 2f, qrBitmap.Height / 2f, circleRadius, circlePaint); + + // Resize and draw logo + using var resizedLogo = logoBitmap.Resize( + new SKImageInfo(logoSize, logoSize), + new SKSamplingOptions(SKCubicResampler.Mitchell)); + if (resizedLogo != null) + { + canvas.DrawBitmap(resizedLogo, logoX, logoY); + } + + // Encode to PNG + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + + return data.ToArray(); + } + private static QRCodeGenerator.ECCLevel ParseEccLevel(string level) { return level.ToUpperInvariant() switch @@ -73,22 +258,20 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService }; } - private static byte[] ParseColor(string hexColor) + private static SKColor ParseSkColor(string hexColor) { // Remove # if present var hex = hexColor.TrimStart('#'); if (hex.Length == 6) { - return - [ - Convert.ToByte(hex[..2], 16), - Convert.ToByte(hex[2..4], 16), - Convert.ToByte(hex[4..6], 16) - ]; + var r = Convert.ToByte(hex[..2], 16); + var g = Convert.ToByte(hex[2..4], 16); + var b = Convert.ToByte(hex[4..6], 16); + return new SKColor(r, g, b); } // Default to black - return [0, 0, 0]; + return SKColors.Black; } } diff --git a/src/api/Features/Redirect/Endpoints/RedirectEndpoint.cs b/src/api/Features/Redirect/Endpoints/RedirectEndpoint.cs index 8012230..bae3f2a 100644 --- a/src/api/Features/Redirect/Endpoints/RedirectEndpoint.cs +++ b/src/api/Features/Redirect/Endpoints/RedirectEndpoint.cs @@ -10,6 +10,7 @@ namespace api.Features.Redirect.Endpoints; public class RedirectRequest { public string Slug { get; set; } = string.Empty; + public Guid? Qr { get; set; } } public class RedirectResponse @@ -24,6 +25,7 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki { Get("/{Slug}"); AllowAnonymous(); + Options(x => x.RequireRateLimiting("redirect")); } public override async Task HandleAsync(RedirectRequest req, CancellationToken ct) @@ -73,8 +75,16 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki return; } - // Track click event asynchronously (fire and forget) - await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext); + // 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; diff --git a/src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs b/src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs index d25e4a6..3bea1df 100644 --- a/src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs +++ b/src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs @@ -1,5 +1,7 @@ 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; @@ -22,7 +24,7 @@ public class CreateWorkspaceValidator : Validator } } -public class CreateWorkspaceEndpoint(AppDbContext db) +public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLimits) : Endpoint { public override void Configure() @@ -34,6 +36,16 @@ public class CreateWorkspaceEndpoint(AppDbContext db) { var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + // Check plan limits + if (!await planLimits.CanCreateWorkspaceAsync(userId, ct)) + { + await HttpContext.Response.SendAsync( + new MessageResponse("Workspace limit reached. Please upgrade your plan to create more workspaces."), + 402, + cancellation: ct); + return; + } + var workspace = new Workspace { Id = Guid.NewGuid(), diff --git a/src/api/Middleware/GlobalExceptionMiddleware.cs b/src/api/Middleware/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..b80285d --- /dev/null +++ b/src/api/Middleware/GlobalExceptionMiddleware.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Text.Json; + +namespace api.Middleware; + +public class GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var (statusCode, message) = exception switch + { + UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Unauthorized access"), + KeyNotFoundException => (HttpStatusCode.NotFound, "Resource not found"), + ArgumentException => (HttpStatusCode.BadRequest, exception.Message), + InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message), + _ => (HttpStatusCode.InternalServerError, "An unexpected error occurred") + }; + + // Log the exception + if (statusCode == HttpStatusCode.InternalServerError) + { + logger.LogError(exception, "Unhandled exception: {Message}", exception.Message); + } + else + { + logger.LogWarning("Request error: {StatusCode} - {Message}", (int)statusCode, message); + } + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)statusCode; + + var response = new ErrorResponse( + StatusCode: (int)statusCode, + Message: message, + TraceId: context.TraceIdentifier + ); + + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + await context.Response.WriteAsync(JsonSerializer.Serialize(response, options)); + } +} + +public record ErrorResponse(int StatusCode, string Message, string TraceId); diff --git a/src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.Designer.cs b/src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.Designer.cs new file mode 100644 index 0000000..1a4f753 --- /dev/null +++ b/src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.Designer.cs @@ -0,0 +1,708 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using api.Data; + +#nullable disable + +namespace api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260130185641_AddQRCodeNameAndLogo")] + partial class AddQRCodeNameAndLogo + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("api.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection>("Scopes") + .HasColumnType("text[]"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("api.Models.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Mime") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VerificationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Hostname") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Domains"); + }); + + modelBuilder.Entity("api.Models.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("EmailVerificationTokens"); + }); + + modelBuilder.Entity("api.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("DedupeKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DeviceType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IpHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QRCodeId") + .HasColumnType("uuid"); + + b.Property("Referrer") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ShortLinkId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("QRCodeId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("ShortLinkId", "Timestamp"); + + b.HasIndex("WorkspaceId", "Timestamp"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("api.Models.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Used") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LogoAssetId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ShortLinkId") + .HasColumnType("uuid"); + + b.Property("StyleJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("LogoAssetId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ShortLinkId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("QrCodeDesigns"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DomainId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("DomainId", "Slug") + .IsUnique(); + + b.ToTable("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionEndsAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Workspaces"); + }); + + modelBuilder.Entity("api.Models.ApiKey", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Asset", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Assets") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Domains") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.EmailVerificationToken", b => + { + b.HasOne("api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("api.Models.Event", b => + { + b.HasOne("api.Models.QRCodeDesign", "QRCode") + .WithMany("Events") + .HasForeignKey("QRCodeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.ShortLink", "ShortLink") + .WithMany("Events") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Events") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("QRCode"); + + b.Navigation("ShortLink"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.PasswordResetToken", b => + { + b.HasOne("api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Projects") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.HasOne("api.Models.Asset", "LogoAsset") + .WithMany() + .HasForeignKey("LogoAssetId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Project", "Project") + .WithMany("QRCodeDesigns") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.ShortLink", "ShortLink") + .WithMany("QRCodeDesigns") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("QRCodeDesigns") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LogoAsset"); + + b.Navigation("Project"); + + b.Navigation("ShortLink"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.HasOne("api.Models.Domain", "Domain") + .WithMany("ShortLinks") + .HasForeignKey("DomainId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Project", "Project") + .WithMany("ShortLinks") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("ShortLinks") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Domain"); + + b.Navigation("Project"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.HasOne("api.Models.User", "Owner") + .WithMany("Workspaces") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.Navigation("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.Navigation("QRCodeDesigns"); + + b.Navigation("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.Navigation("Events"); + + b.Navigation("QRCodeDesigns"); + }); + + modelBuilder.Entity("api.Models.User", b => + { + b.Navigation("Workspaces"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.Navigation("Assets"); + + b.Navigation("Domains"); + + b.Navigation("Events"); + + b.Navigation("Projects"); + + b.Navigation("QRCodeDesigns"); + + b.Navigation("ShortLinks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.cs b/src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.cs new file mode 100644 index 0000000..ac7642d --- /dev/null +++ b/src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace api.Migrations +{ + /// + public partial class AddQRCodeNameAndLogo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StripeSubscriptionId", + table: "Workspaces", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "SubscriptionEndsAt", + table: "Workspaces", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "StripeCustomerId", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "ShortLinks", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "Name", + table: "QrCodeDesigns", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "ApiKeys", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + KeyHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + KeyPrefix = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + LastUsedAt = table.Column(type: "timestamp with time zone", nullable: true), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + Scopes = table.Column>(type: "text[]", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + table.ForeignKey( + name: "FK_ApiKeys_Workspaces_WorkspaceId", + column: x => x.WorkspaceId, + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "EmailVerificationTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Token = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_EmailVerificationTokens", x => x.Id); + table.ForeignKey( + name: "FK_EmailVerificationTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PasswordResetTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Token = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + Used = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); + table.ForeignKey( + name: "FK_PasswordResetTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_KeyHash", + table: "ApiKeys", + column: "KeyHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_WorkspaceId", + table: "ApiKeys", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_EmailVerificationTokens_Token", + table: "EmailVerificationTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_EmailVerificationTokens_UserId", + table: "EmailVerificationTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_Token", + table: "PasswordResetTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_UserId", + table: "PasswordResetTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys"); + + migrationBuilder.DropTable( + name: "EmailVerificationTokens"); + + migrationBuilder.DropTable( + name: "PasswordResetTokens"); + + migrationBuilder.DropColumn( + name: "StripeSubscriptionId", + table: "Workspaces"); + + migrationBuilder.DropColumn( + name: "SubscriptionEndsAt", + table: "Workspaces"); + + migrationBuilder.DropColumn( + name: "StripeCustomerId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "ShortLinks"); + + migrationBuilder.DropColumn( + name: "Name", + table: "QrCodeDesigns"); + } + } +} diff --git a/src/api/Migrations/20260130193730_AddProjectDescription.Designer.cs b/src/api/Migrations/20260130193730_AddProjectDescription.Designer.cs new file mode 100644 index 0000000..43e288a --- /dev/null +++ b/src/api/Migrations/20260130193730_AddProjectDescription.Designer.cs @@ -0,0 +1,711 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using api.Data; + +#nullable disable + +namespace api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260130193730_AddProjectDescription")] + partial class AddProjectDescription + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("api.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection>("Scopes") + .HasColumnType("text[]"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("api.Models.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Mime") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VerificationToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Hostname") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Domains"); + }); + + modelBuilder.Entity("api.Models.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("EmailVerificationTokens"); + }); + + modelBuilder.Entity("api.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("DedupeKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DeviceType") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IpHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QRCodeId") + .HasColumnType("uuid"); + + b.Property("Referrer") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ShortLinkId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("QRCodeId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("ShortLinkId", "Timestamp"); + + b.HasIndex("WorkspaceId", "Timestamp"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("api.Models.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Used") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LogoAssetId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ShortLinkId") + .HasColumnType("uuid"); + + b.Property("StyleJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("LogoAssetId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ShortLinkId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("QrCodeDesigns"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DomainId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("DomainId", "Slug") + .IsUnique(); + + b.ToTable("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionEndsAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Workspaces"); + }); + + modelBuilder.Entity("api.Models.ApiKey", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Asset", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Assets") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Domains") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.EmailVerificationToken", b => + { + b.HasOne("api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("api.Models.Event", b => + { + b.HasOne("api.Models.QRCodeDesign", "QRCode") + .WithMany("Events") + .HasForeignKey("QRCodeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.ShortLink", "ShortLink") + .WithMany("Events") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Events") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("QRCode"); + + b.Navigation("ShortLink"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.PasswordResetToken", b => + { + b.HasOne("api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("Projects") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.HasOne("api.Models.Asset", "LogoAsset") + .WithMany() + .HasForeignKey("LogoAssetId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Project", "Project") + .WithMany("QRCodeDesigns") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.ShortLink", "ShortLink") + .WithMany("QRCodeDesigns") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("QRCodeDesigns") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LogoAsset"); + + b.Navigation("Project"); + + b.Navigation("ShortLink"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.HasOne("api.Models.Domain", "Domain") + .WithMany("ShortLinks") + .HasForeignKey("DomainId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Project", "Project") + .WithMany("ShortLinks") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany("ShortLinks") + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Domain"); + + b.Navigation("Project"); + + b.Navigation("Workspace"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.HasOne("api.Models.User", "Owner") + .WithMany("Workspaces") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("api.Models.Domain", b => + { + b.Navigation("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.Project", b => + { + b.Navigation("QRCodeDesigns"); + + b.Navigation("ShortLinks"); + }); + + modelBuilder.Entity("api.Models.QRCodeDesign", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("api.Models.ShortLink", b => + { + b.Navigation("Events"); + + b.Navigation("QRCodeDesigns"); + }); + + modelBuilder.Entity("api.Models.User", b => + { + b.Navigation("Workspaces"); + }); + + modelBuilder.Entity("api.Models.Workspace", b => + { + b.Navigation("Assets"); + + b.Navigation("Domains"); + + b.Navigation("Events"); + + b.Navigation("Projects"); + + b.Navigation("QRCodeDesigns"); + + b.Navigation("ShortLinks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/api/Migrations/20260130193730_AddProjectDescription.cs b/src/api/Migrations/20260130193730_AddProjectDescription.cs new file mode 100644 index 0000000..0b276ff --- /dev/null +++ b/src/api/Migrations/20260130193730_AddProjectDescription.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace api.Migrations +{ + /// + public partial class AddProjectDescription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Description", + table: "Projects", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Description", + table: "Projects"); + } + } +} diff --git a/src/api/Migrations/AppDbContextModelSnapshot.cs b/src/api/Migrations/AppDbContextModelSnapshot.cs index 1f9c0ef..293d8ad 100644 --- a/src/api/Migrations/AppDbContextModelSnapshot.cs +++ b/src/api/Migrations/AppDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -22,6 +23,57 @@ namespace api.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("api.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection>("Scopes") + .HasColumnType("text[]"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApiKeys"); + }); + modelBuilder.Entity("api.Models.Asset", b => { b.Property("Id") @@ -100,6 +152,38 @@ namespace api.Migrations b.ToTable("Domains"); }); + modelBuilder.Entity("api.Models.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("EmailVerificationTokens"); + }); + modelBuilder.Entity("api.Models.Event", b => { b.Property("Id") @@ -164,6 +248,41 @@ namespace api.Migrations b.ToTable("Events"); }); + modelBuilder.Entity("api.Models.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Used") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PasswordResetTokens"); + }); + modelBuilder.Entity("api.Models.Project", b => { b.Property("Id") @@ -175,6 +294,9 @@ namespace api.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("Description") + .HasColumnType("text"); + b.Property("Name") .IsRequired() .HasMaxLength(100) @@ -204,6 +326,10 @@ namespace api.Migrations b.Property("LogoAssetId") .HasColumnType("uuid"); + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + b.Property("ProjectId") .HasColumnType("uuid"); @@ -246,6 +372,9 @@ namespace api.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + b.Property("DestinationUrl") .IsRequired() .HasMaxLength(2048) @@ -319,6 +448,9 @@ namespace api.Migrations .HasMaxLength(255) .HasColumnType("character varying(255)"); + b.Property("StripeCustomerId") + .HasColumnType("text"); + b.Property("VerifiedAt") .HasColumnType("timestamp with time zone"); @@ -354,6 +486,12 @@ namespace api.Migrations .HasMaxLength(20) .HasColumnType("character varying(20)"); + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionEndsAt") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("OwnerUserId"); @@ -361,6 +499,17 @@ namespace api.Migrations b.ToTable("Workspaces"); }); + modelBuilder.Entity("api.Models.ApiKey", b => + { + b.HasOne("api.Models.Workspace", "Workspace") + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workspace"); + }); + modelBuilder.Entity("api.Models.Asset", b => { b.HasOne("api.Models.Workspace", "Workspace") @@ -383,6 +532,17 @@ namespace api.Migrations b.Navigation("Workspace"); }); + modelBuilder.Entity("api.Models.EmailVerificationToken", b => + { + b.HasOne("api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("api.Models.Event", b => { b.HasOne("api.Models.QRCodeDesign", "QRCode") @@ -409,6 +569,17 @@ namespace api.Migrations b.Navigation("Workspace"); }); + modelBuilder.Entity("api.Models.PasswordResetToken", b => + { + b.HasOne("api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("api.Models.Project", b => { b.HasOne("api.Models.Workspace", "Workspace") diff --git a/src/api/Models/ApiKey.cs b/src/api/Models/ApiKey.cs new file mode 100644 index 0000000..f119edc --- /dev/null +++ b/src/api/Models/ApiKey.cs @@ -0,0 +1,17 @@ +namespace api.Models; + +public class ApiKey +{ + public Guid Id { get; set; } + public Guid WorkspaceId { get; set; } + public required string Name { get; set; } + public required string KeyHash { get; set; } // Only store hash, never the raw key + public required string KeyPrefix { get; set; } // First 8 chars for identification (e.g., "trk_abc1...") + public DateTime CreatedAt { get; set; } + public DateTime? LastUsedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public bool IsActive { get; set; } = true; + public List? Scopes { get; set; } // e.g., ["links:read", "links:write", "qrcodes:read"] + + public Workspace Workspace { get; set; } = null!; +} diff --git a/src/api/Models/EmailVerificationToken.cs b/src/api/Models/EmailVerificationToken.cs new file mode 100644 index 0000000..4e49a42 --- /dev/null +++ b/src/api/Models/EmailVerificationToken.cs @@ -0,0 +1,13 @@ +namespace api.Models; + +public class EmailVerificationToken +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public required string Token { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } + + // Navigation + public User User { get; set; } = null!; +} diff --git a/src/api/Models/PasswordResetToken.cs b/src/api/Models/PasswordResetToken.cs new file mode 100644 index 0000000..58e61de --- /dev/null +++ b/src/api/Models/PasswordResetToken.cs @@ -0,0 +1,14 @@ +namespace api.Models; + +public class PasswordResetToken +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public required string Token { get; set; } + public DateTime ExpiresAt { get; set; } + public bool Used { get; set; } + public DateTime CreatedAt { get; set; } + + // Navigation + public User User { get; set; } = null!; +} diff --git a/src/api/Models/Project.cs b/src/api/Models/Project.cs index e5e3a51..01d3ad5 100644 --- a/src/api/Models/Project.cs +++ b/src/api/Models/Project.cs @@ -5,6 +5,7 @@ public class Project public Guid Id { get; set; } public Guid WorkspaceId { get; set; } public required string Name { get; set; } + public string? Description { get; set; } public DateTime CreatedAt { get; set; } // Navigation properties diff --git a/src/api/Models/QRCodeDesign.cs b/src/api/Models/QRCodeDesign.cs index 0ce56dc..d4d905b 100644 --- a/src/api/Models/QRCodeDesign.cs +++ b/src/api/Models/QRCodeDesign.cs @@ -6,6 +6,7 @@ public class QRCodeDesign public Guid WorkspaceId { get; set; } public Guid? ProjectId { get; set; } public Guid? ShortLinkId { get; set; } + public required string Name { get; set; } public required string StyleJson { get; set; } public Guid? LogoAssetId { get; set; } public DateTime CreatedAt { get; set; } diff --git a/src/api/Models/ShortLink.cs b/src/api/Models/ShortLink.cs index f98b622..0a0f5d0 100644 --- a/src/api/Models/ShortLink.cs +++ b/src/api/Models/ShortLink.cs @@ -20,6 +20,7 @@ public class ShortLink public string? PasswordHash { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + public DateTime? DeletedAt { get; set; } // Soft delete // Navigation properties public Workspace Workspace { get; set; } = null!; diff --git a/src/api/Models/User.cs b/src/api/Models/User.cs index 20bf140..f1a9850 100644 --- a/src/api/Models/User.cs +++ b/src/api/Models/User.cs @@ -5,6 +5,7 @@ public class User public Guid Id { get; set; } public required string Email { get; set; } public required string PasswordHash { get; set; } + public string? StripeCustomerId { get; set; } public DateTime? VerifiedAt { get; set; } public DateTime CreatedAt { get; set; } diff --git a/src/api/Models/Workspace.cs b/src/api/Models/Workspace.cs index b17bdb0..e3e0f9a 100644 --- a/src/api/Models/Workspace.cs +++ b/src/api/Models/Workspace.cs @@ -13,6 +13,8 @@ public class Workspace public Guid OwnerUserId { get; set; } public required string Name { get; set; } public WorkspacePlan Plan { get; set; } = WorkspacePlan.Free; + public string? StripeSubscriptionId { get; set; } + public DateTime? SubscriptionEndsAt { get; set; } public DateTime CreatedAt { get; set; } // Navigation properties diff --git a/src/api/Program.cs b/src/api/Program.cs index 596d814..31355b7 100644 --- a/src/api/Program.cs +++ b/src/api/Program.cs @@ -1,81 +1,217 @@ using System.Text; +using System.Threading.RateLimiting; using api.Data; using api.Features.Auth.Settings; using api.Features.Events.Services; using api.Features.Assets.Services; +using api.Features.Email.Services; +using api.Features.Billing.Services; +using api.Features.Billing.Settings; +using api.Features.Plans.Services; using api.Features.QRCodes.Services; +using api.Middleware; using FastEndpoints; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Serilog; -var builder = WebApplication.CreateBuilder(args); +// Configure Serilog +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File("logs/api-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7) + .CreateLogger(); -// Add cors -if (builder.Environment.IsDevelopment()) +try { + Log.Information("Starting TrakQR API"); + + var builder = WebApplication.CreateBuilder(args); + + // Use Serilog + builder.Host.UseSerilog(); + + // Configure CORS builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { - policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback) - .AllowAnyHeader() - .AllowAnyMethod(); + if (builder.Environment.IsDevelopment()) + { + policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + } + else + { + // Production: configure allowed origins from config + var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() + ?? ["https://trakqr.com"]; + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + } }); }); -} - -// Add services to the container. -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"))); - -// Register application services -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// Configure JWT settings -builder.Services.Configure(builder.Configuration.GetSection("Jwt")); -var jwtSettings = builder.Configuration.GetSection("Jwt").Get()!; - -// Configure authentication -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + // Configure Rate Limiting (skip in Testing environment) + var isTestingEnvironment = builder.Environment.EnvironmentName == "Testing"; + builder.Services.AddRateLimiter(options => { - options.TokenValidationParameters = new TokenValidationParameters + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + // Use very high limits in testing environment + var authLimit = isTestingEnvironment ? 100000 : 10; + var globalLimit = isTestingEnvironment ? 100000 : 100; + var redirectLimit = isTestingEnvironment ? 100000 : 1000; + var apiLimit = isTestingEnvironment ? 100000 : 200; + + // Global rate limit for all endpoints + options.AddPolicy("global", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = globalLimit, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + + // Strict rate limit for authentication endpoints + options.AddPolicy("auth", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = authLimit, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + + // Higher limit for redirect endpoint (public, needs to be fast) + options.AddPolicy("redirect", context => + RateLimitPartition.GetSlidingWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = redirectLimit, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 4, + QueueLimit = 0 + })); + + // API rate limit for authenticated endpoints + options.AddPolicy("api", context => + RateLimitPartition.GetTokenBucketLimiter( + partitionKey: context.User?.Identity?.Name ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new TokenBucketRateLimiterOptions + { + TokenLimit = apiLimit, + ReplenishmentPeriod = TimeSpan.FromMinutes(1), + TokensPerPeriod = apiLimit, + QueueLimit = 0 + })); + }); + + // Add services to the container + builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"))); + + // Register application services + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Configure email service + builder.Services.Configure(builder.Configuration.GetSection("Email")); + var emailProvider = builder.Configuration.GetValue("Email:Provider") ?? "console"; + if (emailProvider == "smtp") + { + builder.Services.AddSingleton(); + } + else + { + // Use console email service for development + builder.Services.AddSingleton(); + } + + // Configure Stripe + builder.Services.Configure(builder.Configuration.GetSection("Stripe")); + builder.Services.AddSingleton(); + + // Configure JWT settings + builder.Services.Configure(builder.Configuration.GetSection("Jwt")); + var jwtSettings = builder.Configuration.GetSection("Jwt").Get()!; + + // Configure authentication + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = jwtSettings.Issuer, - ValidAudience = jwtSettings.Audience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)) + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)) + }; + }); + + builder.Services.AddAuthorization(); + builder.Services.AddFastEndpoints(); + builder.Services.AddOpenApi(); + + var app = builder.Build(); + + // Global error handling middleware (must be first) + app.UseMiddleware(); + + // Request logging middleware + app.UseSerilogRequestLogging(options => + { + options.MessageTemplate = "{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); + diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString()); }; }); -builder.Services.AddAuthorization(); -builder.Services.AddFastEndpoints(); -builder.Services.AddOpenApi(); + app.UseCors(); + app.UseRateLimiter(); -var app = builder.Build(); + // Configure the HTTP request pipeline + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi().CacheOutput(); + app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); }); + } -app.UseCors(); + app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi().CacheOutput(); + app.UseFastEndpoints(); - app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); }); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); } - -app.UseHttpsRedirection(); - -app.UseAuthentication(); -app.UseAuthorization(); - -app.UseFastEndpoints(); - -app.Run(); \ No newline at end of file diff --git a/src/api/api.csproj b/src/api/api.csproj index a7d4eb7..4eba0af 100644 --- a/src/api/api.csproj +++ b/src/api/api.csproj @@ -13,6 +13,7 @@ + @@ -21,6 +22,12 @@ + + + + + + diff --git a/src/api/appsettings.Development.json b/src/api/appsettings.Development.json index 6503447..a9b7480 100644 --- a/src/api/appsettings.Development.json +++ b/src/api/appsettings.Development.json @@ -9,6 +9,24 @@ "PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!" }, "Jwt": { - "Secret": "dev-secret-key-min-32-characters-long-for-hmac256!" + "Secret": "dev-secret-key-min-32-characters-long-for-hmac256!", + "Issuer": "TrakQR", + "Audience": "TrakQR", + "ExpirationMinutes": 60 + }, + "Email": { + "Provider": "console", + "FromEmail": "noreply@trakqr.local", + "FromName": "TrakQR", + "BaseUrl": "http://localhost:5173" + }, + "Cors": { + "AllowedOrigins": ["http://localhost:5173", "https://localhost:5173"] + }, + "Stripe": { + "SecretKey": "sk_test_your_test_key_here", + "WebhookSecret": "whsec_your_webhook_secret_here", + "ProPriceId": "price_pro_monthly", + "BusinessPriceId": "price_business_monthly" } } diff --git a/src/api/appsettings.json b/src/api/appsettings.json index 81b3581..730c2b8 100644 --- a/src/api/appsettings.json +++ b/src/api/appsettings.json @@ -14,5 +14,30 @@ "Issuer": "TrakQR", "Audience": "TrakQR", "ExpirationMinutes": 60 + }, + "Email": { + "Provider": "smtp", + "FromEmail": "noreply@trakqr.com", + "FromName": "TrakQR", + "BaseUrl": "https://trakqr.com", + "Smtp": { + "Host": "", + "Port": 587, + "UseSsl": true, + "Username": "", + "Password": "" + } + }, + "Cors": { + "AllowedOrigins": ["https://trakqr.com"] + }, + "GeoIP": { + "DatabasePath": "" + }, + "Stripe": { + "SecretKey": "", + "WebhookSecret": "", + "ProPriceId": "", + "BusinessPriceId": "" } } diff --git a/src/frontend/src/App.vue b/src/frontend/src/App.vue index 98240ae..eb18d0a 100644 --- a/src/frontend/src/App.vue +++ b/src/frontend/src/App.vue @@ -1,3 +1,22 @@ + + diff --git a/src/frontend/src/api/client.js b/src/frontend/src/api/client.js index 60f6744..2467b10 100644 --- a/src/frontend/src/api/client.js +++ b/src/frontend/src/api/client.js @@ -77,6 +77,38 @@ class ApiClient { return this.request('POST', '/auth/login', { email, password }); } + forgotPassword(email) { + return this.request('POST', '/auth/forgot', { email }); + } + + resetPassword(token, newPassword) { + return this.request('POST', '/auth/reset', { token, newPassword }); + } + + getProfile() { + return this.request('GET', '/auth/profile'); + } + + updateProfile(data) { + return this.request('PUT', '/auth/profile', data); + } + + changePassword(currentPassword, newPassword) { + return this.request('POST', '/auth/change-password', { currentPassword, newPassword }); + } + + resendVerification() { + return this.request('POST', '/auth/resend-verification'); + } + + verifyEmail(token) { + return this.request('POST', '/auth/verify-email', { token }); + } + + deleteAccount(password) { + return this.request('DELETE', '/auth/account', { password }); + } + // Workspaces listWorkspaces() { return this.request('GET', '/workspaces'); @@ -126,6 +158,10 @@ class ApiClient { return this.request('GET', path); } + restoreLink(workspaceId, id) { + return this.request('POST', `/workspaces/${workspaceId}/links/${id}/restore`); + } + createLink(workspaceId, data) { return this.request('POST', `/workspaces/${workspaceId}/links`, data); } @@ -142,8 +178,19 @@ class ApiClient { return this.request('DELETE', `/workspaces/${workspaceId}/links/${id}`); } - getLinkAnalytics(workspaceId, linkId, period = '7d') { - return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?period=${period}`); + bulkCreateLinks(workspaceId, links) { + return this.request('POST', `/workspaces/${workspaceId}/links/bulk`, { links }); + } + + getLinkAnalytics(workspaceId, linkId, period = '7d', startDate = null, endDate = null) { + const params = new URLSearchParams(); + if (startDate && endDate) { + params.set('startDate', startDate); + params.set('endDate', endDate); + } else { + params.set('period', period); + } + return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?${params.toString()}`); } // QR Codes @@ -175,9 +222,20 @@ class ApiClient { return `${API_BASE}/workspaces/${workspaceId}/qrcodes/${id}/export?format=${format}&size=${size}`; } + getQRCodeAnalytics(workspaceId, qrCodeId, period = '7d') { + return this.request('GET', `/workspaces/${workspaceId}/qrcodes/${qrCodeId}/analytics?period=${period}`); + } + // Analytics - getWorkspaceAnalytics(workspaceId, period = '7d') { - return this.request('GET', `/workspaces/${workspaceId}/analytics?period=${period}`); + getWorkspaceAnalytics(workspaceId, period = '7d', startDate = null, endDate = null) { + const params = new URLSearchParams(); + if (startDate && endDate) { + params.set('startDate', startDate); + params.set('endDate', endDate); + } else { + params.set('period', period); + } + return this.request('GET', `/workspaces/${workspaceId}/analytics?${params.toString()}`); } // Domains @@ -209,6 +267,43 @@ class ApiClient { deleteAsset(workspaceId, id) { return this.request('DELETE', `/workspaces/${workspaceId}/assets/${id}`); } + + // Billing + createCheckoutSession(workspaceId, plan, successUrl, cancelUrl) { + return this.request('POST', '/billing/checkout', { + workspaceId, + plan, + successUrl, + cancelUrl, + }); + } + + createPortalSession(returnUrl) { + return this.request('POST', '/billing/portal', { returnUrl }); + } + + getSubscription(workspaceId) { + return this.request('GET', `/workspaces/${workspaceId}/subscription`); + } + + // Usage + getUsage(workspaceId = null) { + const path = workspaceId ? `/usage?workspaceId=${workspaceId}` : '/usage'; + return this.request('GET', path); + } + + // API Keys + listApiKeys(workspaceId) { + return this.request('GET', `/workspaces/${workspaceId}/api-keys`); + } + + createApiKey(workspaceId, name, expiresAt = null, scopes = null) { + return this.request('POST', `/workspaces/${workspaceId}/api-keys`, { name, expiresAt, scopes }); + } + + deleteApiKey(workspaceId, id) { + return this.request('DELETE', `/workspaces/${workspaceId}/api-keys/${id}`); + } } export const api = new ApiClient(); diff --git a/src/frontend/src/components/layout/AppLayout.vue b/src/frontend/src/components/layout/AppLayout.vue index aa9bb94..f2e4949 100644 --- a/src/frontend/src/components/layout/AppLayout.vue +++ b/src/frontend/src/components/layout/AppLayout.vue @@ -9,19 +9,141 @@
- - {{ ws.name }} - - + + +
+ + +
+
+ + + + + + + + + + +
+ Forgot password? +
+ @@ -167,6 +171,19 @@ const handleSubmit = async () => { font-size: 0.9rem; } +.form-actions { + text-align: right; +} + +.forgot-link { + color: var(--muted); + font-size: 0.9rem; +} + +.forgot-link:hover { + color: var(--accent); +} + .cta.full { width: 100%; padding: 14px; diff --git a/src/frontend/src/views/auth/ResetPassword.vue b/src/frontend/src/views/auth/ResetPassword.vue new file mode 100644 index 0000000..c6d7ea6 --- /dev/null +++ b/src/frontend/src/views/auth/ResetPassword.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/src/frontend/src/views/auth/VerifyEmail.vue b/src/frontend/src/views/auth/VerifyEmail.vue new file mode 100644 index 0000000..5ccc753 --- /dev/null +++ b/src/frontend/src/views/auth/VerifyEmail.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/frontend/src/views/billing/Billing.vue b/src/frontend/src/views/billing/Billing.vue new file mode 100644 index 0000000..72f30e1 --- /dev/null +++ b/src/frontend/src/views/billing/Billing.vue @@ -0,0 +1,598 @@ + + + + + diff --git a/src/frontend/src/views/dashboard/Dashboard.vue b/src/frontend/src/views/dashboard/Dashboard.vue index 07bc960..84d1e5c 100644 --- a/src/frontend/src/views/dashboard/Dashboard.vue +++ b/src/frontend/src/views/dashboard/Dashboard.vue @@ -145,6 +145,26 @@

No referrer data yet

+ +
+
+

Top Countries

+
+
+
+
+ + {{ getCountryFlag(country.key) }} + {{ getCountryName(country.key) }} + + {{ country.count }} +
+
+
+

No geographic data yet

+

Country detection requires a GeoIP database

+
+
@@ -189,6 +209,52 @@ const getPercentage = (value) => { return Math.max((value / totalEvents.value) * 100, 5); }; +// Country code to flag emoji converter +const getCountryFlag = (countryCode) => { + if (!countryCode || countryCode.length !== 2) return ''; + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + +// Country code to name mapping (common codes) +const countryNames = { + US: 'United States', + GB: 'United Kingdom', + CA: 'Canada', + AU: 'Australia', + DE: 'Germany', + FR: 'France', + JP: 'Japan', + CN: 'China', + IN: 'India', + BR: 'Brazil', + MX: 'Mexico', + ES: 'Spain', + IT: 'Italy', + NL: 'Netherlands', + SE: 'Sweden', + NO: 'Norway', + DK: 'Denmark', + FI: 'Finland', + PL: 'Poland', + RU: 'Russia', + KR: 'South Korea', + SG: 'Singapore', + NZ: 'New Zealand', + IE: 'Ireland', + CH: 'Switzerland', + AT: 'Austria', + BE: 'Belgium', + PT: 'Portugal', +}; + +const getCountryName = (countryCode) => { + return countryNames[countryCode] || countryCode; +}; + onMounted(async () => { await workspaceStore.fetchLinks(); await workspaceStore.fetchAnalytics(period.value); @@ -417,10 +483,24 @@ watch(() => workspaceStore.currentWorkspaceId, async () => { color: var(--muted); } +.empty-state .hint { + font-size: 0.85rem; + margin-top: 8px; +} + .empty-state .cta { margin-top: 16px; } +.country-bar { + background: rgba(59, 130, 246, 0.15); +} + +.country-flag { + margin-right: 8px; + font-size: 1.1em; +} + .cta.small { padding: 10px 16px; font-size: 0.9rem; diff --git a/src/frontend/src/views/domains/Domains.vue b/src/frontend/src/views/domains/Domains.vue new file mode 100644 index 0000000..7e2b4d6 --- /dev/null +++ b/src/frontend/src/views/domains/Domains.vue @@ -0,0 +1,761 @@ + + + + + diff --git a/src/frontend/src/views/links/LinkDetail.vue b/src/frontend/src/views/links/LinkDetail.vue index 505af2e..1b47d86 100644 --- a/src/frontend/src/views/links/LinkDetail.vue +++ b/src/frontend/src/views/links/LinkDetail.vue @@ -97,6 +97,23 @@

No referrer data yet

+ +
+

Countries

+
+
+
+ + {{ getCountryFlag(country.key) }} + {{ getCountryName(country.key) }} + + {{ country.count }} +
+
+
+

No geographic data yet

+
+