feat: add more features

This commit is contained in:
2026-01-28 15:30:50 -05:00
parent c23156c6b4
commit accdd9ac07
31 changed files with 3377 additions and 3 deletions

279
docs/tasks.md Normal file
View File

@@ -0,0 +1,279 @@
# TrakQR Implementation Tasks
> This file tracks implementation progress. Update status as work completes.
## Status Legend
- [ ] Not started
- [~] In progress / Partial
- [x] Complete
---
## Phase 1: Foundation (Complete)
### Database & Models
- [x] PostgreSQL setup
- [x] EF Core configuration
- [x] User entity
- [x] Workspace entity
- [x] Project entity
- [x] ShortLink entity (model only)
- [x] QRCodeDesign entity (model only)
- [x] Domain entity (model only)
- [x] Event entity (model only)
- [x] Asset entity (model only)
### Authentication
- [x] User registration endpoint (`POST /auth/register`)
- [x] User login endpoint (`POST /auth/login`)
- [x] JWT token generation
- [~] Forgot password endpoint (endpoint exists, email TODO)
- [~] Reset password endpoint (endpoint exists, needs completion)
- [ ] Email verification flow
### Workspaces & Projects
- [x] Create workspace (`POST /workspaces`)
- [x] List workspaces (`GET /workspaces`)
- [x] Get workspace (`GET /workspaces/{id}`)
- [x] Update workspace (`PUT /workspaces/{id}`)
- [x] Delete workspace (`DELETE /workspaces/{id}`)
- [x] Create project (`POST /workspaces/{id}/projects`)
- [x] List projects (`GET /workspaces/{id}/projects`)
- [x] Get project (`GET /workspaces/{id}/projects/{id}`)
- [x] Update project (`PUT /workspaces/{id}/projects/{id}`)
- [x] Delete project (`DELETE /workspaces/{id}/projects/{id}`)
- [x] Auto-create default workspace on signup
- [x] Ownership verification / access control
### Testing Infrastructure
- [x] ApiWebApplicationFactory for integration tests
- [x] Project endpoint tests
- [x] Workspace endpoint tests
- [x] Link endpoint tests
---
## Phase 2: Core Link Features (Next Priority)
### Short Link CRUD
- [x] Create short link endpoint (`POST /workspaces/{id}/links`)
- Custom slug or auto-generate
- URL validation
- Title (optional)
- Project assignment (optional)
- Domain selection (default domain initially)
- [x] List short links (`GET /workspaces/{id}/links`)
- Filter by project
- Filter by status
- Pagination (not yet)
- [x] Get short link (`GET /workspaces/{id}/links/{id}`)
- [x] Update short link (`PUT /workspaces/{id}/links/{id}`)
- Update destination URL
- Update title
- Enable/disable (status)
- Set expiration date
- Set password protection
- [x] Delete short link (`DELETE /workspaces/{id}/links/{id}`)
- [x] Short link tests (15 tests)
### Public Redirect Endpoint
- [x] `GET /{slug}` redirect endpoint
- Resolve domain + slug to destination
- Check link exists
- Check link is active
- Check not expired
- Check password (if protected, return 401 with X-Password-Required header)
- Log event (async, non-blocking) - TODO Phase 3
- Return 302 redirect
- [x] Default domain configuration (using null domain for now)
- [x] Password-protected link handling (`POST /{slug}` with password)
- [x] Redirect endpoint tests (10 tests)
---
## Phase 3: Event Tracking & Analytics
### Event Logging
- [x] Event logging service (`IEventTrackingService`)
- IP hashing (privacy) ✓
- User agent parsing (device type) ✓
- GeoIP lookup (country) - TODO: integrate GeoIP database
- Referrer capture ✓
- Dedupe key generation (30-min window) ✓
- [x] Click event recording (from redirect)
- [~] Scan event recording (from QR) - ready, needs QR endpoints
- [x] Async/background event processing (fire-and-forget)
- [x] Event tracking tests (5 tests)
### Analytics Endpoints
- [x] Workspace analytics (`GET /workspaces/{id}/analytics`)
- Total clicks/scans ✓
- Unique visitors ✓
- Time series data ✓
- Top links breakdown ✓
- Device breakdown ✓
- Referrer breakdown ✓
- [x] Link analytics (`GET /workspaces/{id}/links/{id}/analytics`)
- Per-link stats ✓
- Referrer breakdown ✓
- Device breakdown ✓
- Geo breakdown - TODO: integrate GeoIP database
- [x] Time filters (24h, 7d, 30d, all-time)
- [x] Analytics endpoint tests (9 tests)
---
## Phase 4: QR Code Designer
### QR Code Generation
- [x] QR code generation service (`IQRCodeGeneratorService`)
- Uses QRCoder library ✓
- Support different error correction levels (L/M/Q/H) ✓
- Quiet zone configuration ✓
- PNG and SVG output ✓
- [x] QR code design model integration
- Foreground/background colors ✓
- Module shapes (square) - more shapes TODO
- Eye shapes - TODO
- Logo embedding - TODO (needs asset upload)
### QR Code Endpoints
- [x] Create QR design (`POST /workspaces/{id}/qrcodes`)
- [x] List QR designs (`GET /workspaces/{id}/qrcodes`)
- [x] Get QR design (`GET /workspaces/{id}/qrcodes/{id}`)
- [x] Update QR design (`PUT /workspaces/{id}/qrcodes/{id}`)
- [x] Delete QR design (`DELETE /workspaces/{id}/qrcodes/{id}`)
- [x] Preview QR (`GET /workspaces/{id}/qrcodes/{id}/preview`) - returns data URL
- [x] Export QR as PNG (`GET /workspaces/{id}/qrcodes/{id}/export?format=png&size=512`)
- [x] Export QR as SVG (`GET /workspaces/{id}/qrcodes/{id}/export?format=svg`)
- [x] QR code endpoint tests (12 tests)
### Asset Management (for logos) - TODO
- [ ] Upload asset endpoint (`POST /workspaces/{id}/assets`)
- [ ] List assets (`GET /workspaces/{id}/assets`)
- [ ] Delete asset (`DELETE /workspaces/{id}/assets/{id}`)
- [ ] Asset storage (local/S3)
---
## Phase 5: Domain Management
### Custom Domains
- [ ] Add domain (`POST /workspaces/{id}/domains`)
- [ ] List domains (`GET /workspaces/{id}/domains`)
- [ ] Delete domain (`DELETE /workspaces/{id}/domains/{id}`)
- [ ] Domain verification flow
- Generate verification token
- Check DNS TXT record
- Mark as verified
- [ ] Domain status management (Pending → Verified → Active)
---
## Phase 6: Frontend Dashboard
### Authentication UI
- [ ] Login page
- [ ] Registration page
- [ ] Forgot password page
- [ ] Password reset page
- [ ] Auth state management
### Dashboard
- [ ] Workspace switcher
- [ ] Dashboard home (overview stats)
- [ ] Navigation/sidebar
### Link Management UI
- [ ] Links list view
- [ ] Create link modal/page
- [ ] Edit link modal/page
- [ ] Link details with analytics
### QR Designer UI
- [ ] QR designer page
- [ ] Color pickers
- [ ] Shape selectors
- [ ] Logo upload
- [ ] Live preview
- [ ] Export buttons
### Analytics UI
- [ ] Charts (time series)
- [ ] Stat cards
- [ ] Breakdown tables (referrer, geo, device)
---
## Phase 7: Production Readiness
### Security & Performance
- [ ] Rate limiting
- [ ] Input sanitization
- [ ] CORS configuration
- [ ] Request logging
- [ ] Error handling middleware
### Email System
- [ ] Email service integration (SendGrid/SES/etc.)
- [ ] Email verification emails
- [ ] Password reset emails
- [ ] Email templates
### Plan & Quotas
- [ ] Usage tracking
- [ ] Plan limits enforcement
- Free: 50 links, 1 workspace
- Pro: 5,000 links, 5 workspaces
- Business: Unlimited
- [ ] Upgrade prompts
---
## Phase 8: Post-MVP Features
### Payments (Stripe)
- [ ] Stripe integration
- [ ] Checkout flow
- [ ] Subscription management
- [ ] Webhook handling
### Advanced Features
- [ ] UTM builder
- [ ] Link groups/campaigns
- [ ] Bulk link creation
- [ ] API keys for external access
- [ ] Webhooks for events
---
## Current Focus
**Completed: Phase 2 + Phase 3 + Phase 4**
- Short Link CRUD (5 endpoints, 15 tests)
- Public Redirect Endpoint (2 endpoints, 10 tests)
- Event Tracking Service (click logging, dedupe, device detection)
- Analytics Endpoints (2 endpoints, 9 tests)
- QR Code Designer (7 endpoints, 12 tests)
**Total: 81 tests passing**
**Next up: Phase 5 - Domain Management** or **Frontend Dashboard**
Completed:
1. ~~Create short link endpoint with auto-slug generation~~
2. ~~List/Get/Update/Delete short link endpoints~~
3. ~~Public redirect endpoint (`GET /{slug}`)~~
4. ~~Password redirect endpoint (`POST /{slug}`)~~
5. ~~Event logging (basic click tracking)~~
6. ~~Analytics endpoints~~
7. ~~QR code generation and designer~~
---
## Notes
- Backend uses FastEndpoints (not traditional MVC controllers)
- Vertical slice architecture: features in `src/api/Features/{Feature}/`
- All endpoints require JWT auth except public redirect
- Default domain: use app's domain until custom domains implemented

View File

@@ -0,0 +1,220 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Api.Tests;
public class AnalyticsEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly HttpClient _noRedirectClient;
public AnalyticsEndpointTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string slug)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = destinationUrl,
Slug = slug
});
return (await createResponse.Content.ReadFromJsonAsync<LinkResponse>())!;
}
[Fact]
public async Task WorkspaceAnalytics_ReturnsEmptyForNewWorkspace()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-empty@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceAnalyticsResponse>();
result.Should().NotBeNull();
result!.Summary.TotalClicks.Should().Be(0);
result.Summary.TotalScans.Should().Be(0);
result.Summary.UniqueVisitors.Should().Be(0);
}
[Fact]
public async Task WorkspaceAnalytics_ReturnsValidResponse()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-clicks@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "https://example.com", "analytics-clicks-link");
// Generate some clicks (event tracking is async/fire-and-forget)
await _noRedirectClient.GetAsync($"/{link.Slug}");
// Act - Verify endpoint returns valid response structure
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics");
// Assert - Focus on response structure, not click counts (tested separately)
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceAnalyticsResponse>();
result.Should().NotBeNull();
result!.Summary.Should().NotBeNull();
result.TimeSeries.Should().NotBeNull();
result.TopLinks.Should().NotBeNull();
result.DeviceBreakdown.Should().NotBeNull();
result.ReferrerBreakdown.Should().NotBeNull();
}
[Fact]
public async Task WorkspaceAnalytics_WithPeriodFilter_FiltersEvents()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-period@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics?period=24h");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task WorkspaceAnalytics_Unauthorized_Returns401()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-unauth@example.com");
_client.DefaultRequestHeaders.Authorization = null;
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task WorkspaceAnalytics_OtherUsersWorkspace_Returns404()
{
// Arrange
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("analytics-user1@example.com");
var (token2, _) = await SetupAuthAndWorkspaceAsync("analytics-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId1}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task LinkAnalytics_ReturnsEmptyForNewLink()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("link-analytics-empty@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "https://example.com", "link-analytics-empty");
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{link.Id}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkAnalyticsResponse>();
result.Should().NotBeNull();
result!.LinkId.Should().Be(link.Id);
result.Slug.Should().Be(link.Slug);
result.Summary.TotalClicks.Should().Be(0);
}
[Fact]
public async Task LinkAnalytics_ReturnsValidResponse()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("link-analytics-clicks@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "https://example.com", "link-analytics-clicks");
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{link.Id}/analytics");
// Assert - Focus on response structure
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkAnalyticsResponse>();
result.Should().NotBeNull();
result!.LinkId.Should().Be(link.Id);
result.Slug.Should().Be(link.Slug);
result.Summary.Should().NotBeNull();
result.TimeSeries.Should().NotBeNull();
result.DeviceBreakdown.Should().NotBeNull();
result.ReferrerBreakdown.Should().NotBeNull();
}
[Fact]
public async Task LinkAnalytics_InvalidLink_Returns404()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("link-analytics-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{Guid.NewGuid()}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task LinkAnalytics_OtherUsersLink_Returns404()
{
// Arrange
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("link-analytics-user1@example.com");
var link = await CreateLinkAsync(workspaceId1, "https://example.com", "link-analytics-user1");
var (token2, _) = await SetupAuthAndWorkspaceAsync("link-analytics-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{link.Id}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,142 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Api.Tests;
public class EventTrackingTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly HttpClient _noRedirectClient;
public EventTrackingTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
_noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string slug)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = destinationUrl,
Slug = slug
});
return (await createResponse.Content.ReadFromJsonAsync<LinkResponse>())!;
}
[Fact]
public async Task Redirect_TracksClickEvent()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-track@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-track-link");
// Act - Click the link multiple times
await _noRedirectClient.GetAsync($"/{link.Slug}");
// Give time for async event tracking
await Task.Delay(500);
// We can't directly query the events without an analytics endpoint,
// but we can verify the redirect still works and the endpoint doesn't crash
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_DeduplicatesEvents_WithinWindow()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-dedupe@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-dedupe-link");
// Act - Click the same link multiple times rapidly
var responses = new List<HttpResponseMessage>();
for (int i = 0; i < 5; i++)
{
responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
}
// Assert - All should redirect successfully (deduplication happens silently)
responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Redirect);
}
[Fact]
public async Task PasswordRedirect_TracksClickEvent()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-pass@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-pass-link");
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link.Id}", new { Password = "secret" });
// Act
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_CapturesUserAgent()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-ua@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ua-link");
// Set a custom user agent
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
// Act
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert - Redirect should work (event captures user agent in background)
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_CapturesReferrer()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-ref@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ref-link");
// Set a referrer
_noRedirectClient.DefaultRequestHeaders.Referrer = new Uri("https://twitter.com/somepost");
// Act
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}

View File

@@ -0,0 +1,383 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Projects.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
namespace Api.Tests;
public class LinkEndpointTests(ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
[Fact]
public async Task CreateLink_WithValidData_ReturnsCreated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Title = "Example Link"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result.Should().NotBeNull();
result!.DestinationUrl.Should().Be("https://example.com");
result.Title.Should().Be("Example Link");
result.Slug.Should().NotBeNullOrEmpty();
result.Slug.Should().HaveLength(7); // Default slug length
result.Status.Should().Be("Active");
result.WorkspaceId.Should().Be(workspaceId);
}
[Fact]
public async Task CreateLink_WithCustomSlug_ReturnsCreatedWithSlug()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-slug@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Slug = "my-custom-slug"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.Slug.Should().Be("my-custom-slug");
}
[Fact]
public async Task CreateLink_WithDuplicateSlug_ReturnsConflict()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-dup@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Slug = "duplicate-slug"
});
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://another.com",
Slug = "duplicate-slug"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
[Fact]
public async Task CreateLink_WithInvalidUrl_ReturnsBadRequest()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "not-a-valid-url"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task CreateLink_WithProject_AssignsToProject()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
ProjectId = project!.Id
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.ProjectId.Should().Be(project.Id);
}
[Fact]
public async Task ListLinks_WithValidWorkspace_ReturnsLinks()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example1.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://example2.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkListResponse>();
result!.Links.Should().HaveCountGreaterThanOrEqualTo(2);
}
[Fact]
public async Task ListLinks_FilterByProject_ReturnsFilteredLinks()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var projectResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new { DestinationUrl = "https://no-project.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkListResponse>();
result!.Links.Should().OnlyContain(l => l.ProjectId == project.Id);
}
[Fact]
public async Task GetLink_WithValidId_ReturnsLink()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Title = "Get Test"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.Id.Should().Be(created.Id);
result.Title.Should().Be("Get Test");
}
[Fact]
public async Task GetLink_WithInvalidId_ReturnsNotFound()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-link-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task UpdateLink_WithValidData_ReturnsUpdated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://original.com",
Title = "Original"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created!.Id}", new
{
DestinationUrl = "https://updated.com",
Title = "Updated"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.DestinationUrl.Should().Be("https://updated.com");
result.Title.Should().Be("Updated");
}
[Fact]
public async Task UpdateLink_SetStatus_UpdatesStatus()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link-status@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created!.Id}", new
{
Status = "Disabled"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.Status.Should().Be("Disabled");
}
[Fact]
public async Task UpdateLink_SetPassword_AddsPassword()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link-pass@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
created!.HasPassword.Should().BeFalse();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created.Id}", new
{
Password = "secret123"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.HasPassword.Should().BeTrue();
}
[Fact]
public async Task UpdateLink_RemovePassword_RemovesPassword()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link-rmpass@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created!.Id}", new { Password = "secret123" });
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created.Id}", new
{
RemovePassword = true
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.HasPassword.Should().BeFalse();
}
[Fact]
public async Task DeleteLink_WithValidId_ReturnsSuccess()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/links/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId}/links/{created.Id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Link_CannotAccessOtherUsersLinks()
{
// Arrange - Create two users
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("user1-link@example.com");
var (token2, _) = await SetupAuthAndWorkspaceAsync("user2-link@example.com");
// Create link as user1
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/links", new
{
DestinationUrl = "https://user1.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Try to access as user2
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{created!.Id}");
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/links/{created.Id}");
// Assert - All should return NotFound (not exposing existence)
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,299 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.QRCodes.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
namespace Api.Tests;
public class QRCodeEndpointTests(ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string slug)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Slug = slug
});
return (await createResponse.Content.ReadFromJsonAsync<LinkResponse>())!;
}
[Fact]
public async Task CreateQRCode_WithValidData_ReturnsCreated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-create@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-create-link");
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new
{
ShortLinkId = link.Id
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result.Should().NotBeNull();
result!.ShortLinkId.Should().Be(link.Id);
result.ShortLinkSlug.Should().Be(link.Slug);
result.Style.Should().NotBeNull();
result.Style.ForegroundColor.Should().Be("#000000");
result.Style.BackgroundColor.Should().Be("#FFFFFF");
}
[Fact]
public async Task CreateQRCode_WithCustomStyle_ReturnsCreatedWithStyle()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-style@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-style-link");
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new
{
ShortLinkId = link.Id,
Style = new
{
ForegroundColor = "#FF0000",
BackgroundColor = "#00FF00",
ErrorCorrectionLevel = "H"
}
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result!.Style.ForegroundColor.Should().Be("#FF0000");
result.Style.BackgroundColor.Should().Be("#00FF00");
result.Style.ErrorCorrectionLevel.Should().Be("H");
}
[Fact]
public async Task CreateQRCode_WithoutShortLink_ReturnsBadRequest()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-nolink@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task ListQRCodes_ReturnsQRCodes()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-list@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-list-link");
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodeListResponse>();
result!.QRCodes.Should().HaveCountGreaterThanOrEqualTo(1);
}
[Fact]
public async Task GetQRCode_WithValidId_ReturnsQRCode()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-get@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-get-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result!.Id.Should().Be(created.Id);
}
[Fact]
public async Task GetQRCode_WithInvalidId_ReturnsNotFound()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-get-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task UpdateQRCode_WithValidData_ReturnsUpdated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-update@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-update-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}", new
{
Style = new
{
ForegroundColor = "#0000FF",
BackgroundColor = "#FFFF00"
}
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result!.Style.ForegroundColor.Should().Be("#0000FF");
result.Style.BackgroundColor.Should().Be("#FFFF00");
}
[Fact]
public async Task DeleteQRCode_WithValidId_ReturnsSuccess()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-delete@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-delete-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created.Id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task PreviewQRCode_ReturnsDataUrl()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-preview@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-preview-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}/preview");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodePreviewResponse>();
result!.DataUrl.Should().StartWith("data:image/png;base64,");
result.Format.Should().Be("png");
}
[Fact]
public async Task ExportQRCode_AsPng_ReturnsPngImage()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-export-png@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-export-png-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}/export?format=png");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("image/png");
}
[Fact]
public async Task ExportQRCode_AsSvg_ReturnsSvgImage()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-export-svg@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-export-svg-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}/export?format=svg");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("image/svg+xml");
}
[Fact]
public async Task QRCode_CannotAccessOtherUsersQRCode()
{
// Arrange - Create two users
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("qr-user1@example.com");
var link = await CreateLinkAsync(workspaceId1, "qr-user1-link");
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
var (token2, _) = await SetupAuthAndWorkspaceAsync("qr-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/qrcodes/{created!.Id}");
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes/{created.Id}", new { });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/qrcodes/{created.Id}");
// Assert - All should return NotFound
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,215 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Features.Workspaces.Common;
using FluentAssertions;
namespace Api.Tests;
public class RedirectEndpointTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly HttpClient _noRedirectClient;
public RedirectEndpointTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
// Create a client that doesn't follow redirects
_noRedirectClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
{
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
}
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null, string? password = null)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = destinationUrl,
Slug = slug
});
var link = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
if (password != null)
{
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link!.Id}", new { Password = password });
var updated = await _client.GetAsync($"/workspaces/{workspaceId}/links/{link.Id}");
return (await updated.Content.ReadFromJsonAsync<LinkResponse>())!;
}
return link!;
}
[Fact]
public async Task Redirect_WithValidSlug_Returns302Redirect()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-valid@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "test-redirect");
// Act - Use no-redirect client to capture the redirect
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
response.Headers.Location.Should().NotBeNull();
response.Headers.Location!.ToString().Should().StartWith("https://example.com");
}
[Fact]
public async Task Redirect_WithNonExistentSlug_Returns404()
{
// Act
var response = await _client.GetAsync("/nonexistent-slug-12345");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Redirect_WithDisabledLink_Returns404()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-disabled@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "disabled-link");
// Disable the link
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link.Id}", new { Status = "Disabled" });
// Act
var response = await _client.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Redirect_WithExpiredLink_Returns410()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-expired@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "expired-link");
// Set expiration to the past
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link.Id}", new
{
ExpiresAt = DateTime.UtcNow.AddDays(-1)
});
// Act
var response = await _client.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Gone);
}
[Fact]
public async Task Redirect_WithPasswordProtectedLink_Returns401()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-password@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", password: "secret123");
// Act
var response = await _client.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
response.Headers.Contains("X-Password-Required").Should().BeTrue();
}
[Fact]
public async Task PasswordRedirect_WithCorrectPassword_Returns302Redirect()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-ok@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", password: "secret123");
// Act
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
response.Headers.Location!.ToString().Should().StartWith("https://example.com");
}
[Fact]
public async Task PasswordRedirect_WithWrongPassword_Returns401()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-wrong@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", password: "secret123");
// Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task PasswordRedirect_WithEmptyPassword_Returns400()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-empty@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", password: "secret123");
// Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task PasswordRedirect_NonProtectedLink_RedirectsAnyway()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-no-pass@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "no-password-link");
// Act - POST to non-password protected link
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "anything" });
// Assert - Should still redirect since link doesn't require password
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_DoesNotRequireAuthentication()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-anon@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "anon-redirect-link");
// Remove auth header
_noRedirectClient.DefaultRequestHeaders.Authorization = null;
// Act
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}

View File

@@ -0,0 +1,38 @@
namespace api.Features.Analytics.Common;
public record AnalyticsSummary(
int TotalClicks,
int TotalScans,
int UniqueVisitors,
DateTime? FirstEvent,
DateTime? LastEvent
);
public record TimeSeriesPoint(
DateTime Date,
int Clicks,
int Scans
);
public record BreakdownItem(
string Key,
int Count,
double Percentage
);
public record WorkspaceAnalyticsResponse(
AnalyticsSummary Summary,
IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> TopLinks,
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown
);
public record LinkAnalyticsResponse(
Guid LinkId,
string Slug,
AnalyticsSummary Summary,
IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown
);

View File

@@ -0,0 +1,137 @@
using System.Security.Claims;
using api.Data;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Analytics.Endpoints;
public class LinkAnalyticsRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
}
public class LinkAnalyticsEndpoint(AppDbContext db)
: Endpoint<LinkAnalyticsRequest, LinkAnalyticsResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/links/{Id}/analytics");
}
public override async Task HandleAsync(LinkAnalyticsRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Get link and verify ownership
var link = await db.ShortLinks
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId)
.Select(l => new { l.Id, l.Slug })
.FirstOrDefaultAsync(ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
// Determine time filter
var startDate = GetStartDate(req.Period);
// Query events for this link
var eventsQuery = db.Events
.Where(e => e.ShortLinkId == req.Id);
if (startDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
// Build summary
var summary = new AnalyticsSummary(
TotalClicks: events.Count(e => e.Type == EventType.Click),
TotalScans: events.Count(e => e.Type == EventType.Scan),
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
FirstEvent: events.MinBy(e => e.Timestamp)?.Timestamp,
LastEvent: events.MaxBy(e => e.Timestamp)?.Timestamp
);
// Build time series
var timeSeries = events
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
Date: g.Key,
Clicks: g.Count(e => e.Type == EventType.Click),
Scans: g.Count(e => e.Type == EventType.Scan)
))
.ToList();
// Device breakdown
var deviceBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
.GroupBy(e => e.DeviceType!)
.OrderByDescending(g => g.Count())
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Referrer breakdown
var referrerBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.Referrer))
.GroupBy(e => ExtractDomain(e.Referrer!))
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new LinkAnalyticsResponse(
LinkId: link.Id,
Slug: link.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
{
return period?.ToLower() switch
{
"24h" => DateTime.UtcNow.AddHours(-24),
"7d" => DateTime.UtcNow.AddDays(-7),
"30d" => DateTime.UtcNow.AddDays(-30),
_ => null
};
}
private static string ExtractDomain(string url)
{
try
{
var uri = new Uri(url);
return uri.Host;
}
catch
{
return url.Length > 50 ? url[..50] : url;
}
}
}

View File

@@ -0,0 +1,151 @@
using System.Security.Claims;
using api.Data;
using api.Features.Analytics.Common;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Analytics.Endpoints;
public class WorkspaceAnalyticsRequest
{
public Guid WorkspaceId { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
}
public class WorkspaceAnalyticsEndpoint(AppDbContext db)
: Endpoint<WorkspaceAnalyticsRequest, WorkspaceAnalyticsResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/analytics");
}
public override async Task HandleAsync(WorkspaceAnalyticsRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Determine time filter
var startDate = GetStartDate(req.Period);
// Query events
var eventsQuery = db.Events
.Where(e => e.WorkspaceId == req.WorkspaceId);
if (startDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
}
var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count;
// Get summary
var summary = new AnalyticsSummary(
TotalClicks: events.Count(e => e.Type == EventType.Click),
TotalScans: events.Count(e => e.Type == EventType.Scan),
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count(),
FirstEvent: events.Count > 0 ? events.Min(e => e.Timestamp) : null,
LastEvent: events.Count > 0 ? events.Max(e => e.Timestamp) : null
);
// Get time series
var timeSeries = events
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new TimeSeriesPoint(
Date: g.Key,
Clicks: g.Count(e => e.Type == EventType.Click),
Scans: g.Count(e => e.Type == EventType.Scan)
))
.ToList();
// Get top links - group events by link and get slugs
var linkIds = events.Select(e => e.ShortLinkId).Distinct().ToList();
var linkSlugs = await db.ShortLinks
.Where(l => linkIds.Contains(l.Id))
.Select(l => new { l.Id, l.Slug })
.ToDictionaryAsync(l => l.Id, l => l.Slug, ct);
var topLinks = events
.GroupBy(e => e.ShortLinkId)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
linkSlugs.GetValueOrDefault(g.Key, "unknown"),
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Get device breakdown
var deviceBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
.GroupBy(e => e.DeviceType!)
.OrderByDescending(g => g.Count())
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
// Get referrer breakdown
var referrerBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.Referrer))
.GroupBy(e => ExtractDomain(e.Referrer!))
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new WorkspaceAnalyticsResponse(
Summary: summary,
TimeSeries: timeSeries,
TopLinks: topLinks,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
private static DateTime? GetStartDate(string? period)
{
return period?.ToLower() switch
{
"24h" => DateTime.UtcNow.AddHours(-24),
"7d" => DateTime.UtcNow.AddDays(-7),
"30d" => DateTime.UtcNow.AddDays(-30),
_ => null
};
}
private static string ExtractDomain(string url)
{
try
{
var uri = new Uri(url);
return uri.Host;
}
catch
{
return url.Length > 50 ? url[..50] : url;
}
}
}

View File

@@ -0,0 +1,166 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Events.Services;
public interface IEventTrackingService
{
Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context);
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
}
public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<EventTrackingService> logger)
: IEventTrackingService
{
// Dedupe window - same visitor clicking same link within this window counts as one
private static readonly TimeSpan DedupeWindow = TimeSpan.FromMinutes(30);
public Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context)
{
// Fire and forget - don't block the redirect
_ = Task.Run(async () =>
{
try
{
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, context);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to track click event for link {ShortLinkId}", shortLinkId);
}
});
return Task.CompletedTask;
}
public Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context)
{
// Fire and forget - don't block the redirect
_ = Task.Run(async () =>
{
try
{
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, context);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to track scan event for QR {QRCodeId}", qrCodeId);
}
});
return Task.CompletedTask;
}
private async Task TrackEventInternalAsync(
Guid workspaceId,
Guid shortLinkId,
Guid? qrCodeId,
EventType eventType,
HttpContext context)
{
// Create a new scope for database access (since we're in a background task)
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var ipAddress = GetClientIpAddress(context);
var userAgent = context.Request.Headers.UserAgent.ToString();
var referrer = context.Request.Headers.Referer.ToString();
var ipHash = HashIpAddress(ipAddress);
var deviceType = ParseDeviceType(userAgent);
var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId);
// Check for duplicate within the dedupe window
var cutoff = DateTime.UtcNow.Subtract(DedupeWindow);
var isDuplicate = await db.Events
.AnyAsync(e => e.DedupeKey == dedupeKey && e.Timestamp > cutoff);
if (isDuplicate)
{
logger.LogDebug("Skipping duplicate event for link {ShortLinkId}", shortLinkId);
return;
}
var evt = new Event
{
WorkspaceId = workspaceId,
ShortLinkId = shortLinkId,
QRCodeId = qrCodeId,
Type = eventType,
Timestamp = DateTime.UtcNow,
IpHash = ipHash,
UserAgent = TruncateString(userAgent, 512),
Referrer = TruncateString(referrer, 2048),
CountryCode = null, // TODO: GeoIP lookup
DeviceType = deviceType,
DedupeKey = dedupeKey
};
db.Events.Add(evt);
await db.SaveChangesAsync();
logger.LogDebug("Tracked {EventType} event for link {ShortLinkId}", eventType, shortLinkId);
}
private static string GetClientIpAddress(HttpContext context)
{
// Check for forwarded headers (when behind a proxy/load balancer)
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(forwardedFor))
{
// Take the first IP in the chain (client IP)
return forwardedFor.Split(',')[0].Trim();
}
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
private static string HashIpAddress(string ipAddress)
{
// Use SHA256 to hash the IP for privacy
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ipAddress));
return Convert.ToHexString(bytes)[..16]; // First 16 chars is enough
}
private static string ParseDeviceType(string userAgent)
{
if (string.IsNullOrEmpty(userAgent))
return "Unknown";
var ua = userAgent.ToLowerInvariant();
// Check for mobile devices
if (Regex.IsMatch(ua, @"mobile|android|iphone|ipad|ipod|blackberry|windows phone"))
{
if (Regex.IsMatch(ua, @"ipad|tablet|android(?!.*mobile)"))
return "Tablet";
return "Mobile";
}
// Check for bots/crawlers
if (Regex.IsMatch(ua, @"bot|crawler|spider|slurp|googlebot|bingbot"))
return "Bot";
return "Desktop";
}
private static string GenerateDedupeKey(string ipHash, Guid shortLinkId, Guid? qrCodeId)
{
// Combine IP hash + link ID + optional QR ID
var combined = $"{ipHash}:{shortLinkId}:{qrCodeId?.ToString() ?? "none"}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
return Convert.ToHexString(bytes)[..32];
}
private static string? TruncateString(string? value, int maxLength)
{
if (string.IsNullOrEmpty(value))
return null;
return value.Length <= maxLength ? value : value[..maxLength];
}
}

View File

@@ -0,0 +1,20 @@
namespace api.Features.Links.Common;
public record LinkResponse(
Guid Id,
Guid WorkspaceId,
Guid? ProjectId,
Guid? DomainId,
string Slug,
string DestinationUrl,
string? Title,
string Status,
DateTime? ExpiresAt,
bool HasPassword,
DateTime CreatedAt,
DateTime UpdatedAt
);
public record LinkListResponse(
IEnumerable<LinkResponse> Links
);

View File

@@ -0,0 +1,14 @@
using System.Security.Cryptography;
namespace api.Features.Links.Common;
public static class SlugGenerator
{
private const string Chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private const int DefaultLength = 7;
public static string Generate(int length = DefaultLength)
{
return RandomNumberGenerator.GetString(Chars, length);
}
}

View File

@@ -0,0 +1,139 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Links.Endpoints;
public class CreateLinkRequest
{
public Guid WorkspaceId { get; set; }
public string DestinationUrl { get; set; } = string.Empty;
public string? Slug { get; set; }
public string? Title { get; set; }
public Guid? ProjectId { get; set; }
}
public class CreateLinkValidator : Validator<CreateLinkRequest>
{
public CreateLinkValidator()
{
RuleFor(x => x.DestinationUrl)
.NotEmpty().WithMessage("Destination URL is required")
.MaximumLength(2048).WithMessage("Destination URL must not exceed 2048 characters")
.Must(BeAValidUrl).WithMessage("Destination URL must be a valid URL");
RuleFor(x => x.Slug)
.MaximumLength(50).WithMessage("Slug must not exceed 50 characters")
.Matches(@"^[a-zA-Z0-9_-]*$").WithMessage("Slug can only contain letters, numbers, hyphens, and underscores")
.When(x => !string.IsNullOrEmpty(x.Slug));
RuleFor(x => x.Title)
.MaximumLength(255).WithMessage("Title must not exceed 255 characters");
}
private static bool BeAValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
}
public class CreateLinkEndpoint(AppDbContext db)
: Endpoint<CreateLinkRequest, LinkResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/links");
}
public override async Task HandleAsync(CreateLinkRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Verify project belongs to workspace if specified
if (req.ProjectId.HasValue)
{
var projectExists = await db.Projects
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
if (!projectExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
return;
}
}
// Generate or validate slug
var slug = req.Slug;
if (string.IsNullOrEmpty(slug))
{
// Auto-generate unique slug
do
{
slug = SlugGenerator.Generate();
} while (await db.ShortLinks.AnyAsync(l => l.Slug == slug && l.DomainId == null, ct));
}
else
{
// Check if custom slug is already taken (on default domain)
var slugExists = await db.ShortLinks
.AnyAsync(l => l.Slug == slug && l.DomainId == null, ct);
if (slugExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Slug is already taken"), 409, cancellation: ct);
return;
}
}
var now = DateTime.UtcNow;
var link = new ShortLink
{
Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId,
ProjectId = req.ProjectId,
DomainId = null, // Default domain for now
Slug = slug,
DestinationUrl = req.DestinationUrl,
Title = req.Title,
Status = ShortLinkStatus.Active,
CreatedAt = now,
UpdatedAt = now
};
db.ShortLinks.Add(link);
await db.SaveChangesAsync(ct);
var response = new LinkResponse(
link.Id,
link.WorkspaceId,
link.ProjectId,
link.DomainId,
link.Slug,
link.DestinationUrl,
link.Title,
link.Status.ToString(),
link.ExpiresAt,
link.PasswordHash != null,
link.CreatedAt,
link.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}

View File

@@ -0,0 +1,42 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Links.Endpoints;
public class DeleteLinkRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class DeleteLinkEndpoint(AppDbContext db)
: Endpoint<DeleteLinkRequest>
{
public override void Configure()
{
Delete("/workspaces/{WorkspaceId}/links/{Id}");
}
public override async Task HandleAsync(DeleteLinkRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks
.Include(l => l.Workspace)
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
db.ShortLinks.Remove(link);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,54 @@
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 GetLinkRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class GetLinkEndpoint(AppDbContext db)
: Endpoint<GetLinkRequest, LinkResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/links/{Id}");
}
public override async Task HandleAsync(GetLinkRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId)
.Select(l => new LinkResponse(
l.Id,
l.WorkspaceId,
l.ProjectId,
l.DomainId,
l.Slug,
l.DestinationUrl,
l.Title,
l.Status.ToString(),
l.ExpiresAt,
l.PasswordHash != null,
l.CreatedAt,
l.UpdatedAt
))
.FirstOrDefaultAsync(ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
await HttpContext.Response.SendAsync(link, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,74 @@
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 ListLinksRequest
{
public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; }
public string? Status { get; set; }
}
public class ListLinksEndpoint(AppDbContext db)
: Endpoint<ListLinksRequest, LinkListResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/links");
}
public override async Task HandleAsync(ListLinksRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var query = db.ShortLinks
.Where(l => l.WorkspaceId == req.WorkspaceId);
// Filter by project if specified
if (req.ProjectId.HasValue)
{
query = query.Where(l => l.ProjectId == req.ProjectId.Value);
}
// Filter by status if specified
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<Models.ShortLinkStatus>(req.Status, true, out var status))
{
query = query.Where(l => l.Status == status);
}
var links = await query
.OrderByDescending(l => l.CreatedAt)
.Select(l => new LinkResponse(
l.Id,
l.WorkspaceId,
l.ProjectId,
l.DomainId,
l.Slug,
l.DestinationUrl,
l.Title,
l.Status.ToString(),
l.ExpiresAt,
l.PasswordHash != null,
l.CreatedAt,
l.UpdatedAt
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new LinkListResponse(links), 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,149 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Links.Endpoints;
public class UpdateLinkRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string? DestinationUrl { get; set; }
public string? Title { get; set; }
public string? Status { get; set; }
public DateTime? ExpiresAt { get; set; }
public string? Password { get; set; }
public bool? RemovePassword { get; set; }
public Guid? ProjectId { get; set; }
public bool? RemoveProject { get; set; }
}
public class UpdateLinkValidator : Validator<UpdateLinkRequest>
{
public UpdateLinkValidator()
{
RuleFor(x => x.DestinationUrl)
.MaximumLength(2048).WithMessage("Destination URL must not exceed 2048 characters")
.Must(BeAValidUrl).WithMessage("Destination URL must be a valid URL")
.When(x => !string.IsNullOrEmpty(x.DestinationUrl));
RuleFor(x => x.Title)
.MaximumLength(255).WithMessage("Title must not exceed 255 characters");
RuleFor(x => x.Status)
.Must(s => s == null || Enum.TryParse<ShortLinkStatus>(s, true, out _))
.WithMessage("Status must be 'Active' or 'Disabled'");
RuleFor(x => x.Password)
.MinimumLength(4).WithMessage("Password must be at least 4 characters")
.When(x => !string.IsNullOrEmpty(x.Password));
}
private static bool BeAValidUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return true;
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
}
public class UpdateLinkEndpoint(AppDbContext db)
: Endpoint<UpdateLinkRequest, LinkResponse>
{
public override void Configure()
{
Put("/workspaces/{WorkspaceId}/links/{Id}");
}
public override async Task HandleAsync(UpdateLinkRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks
.Include(l => l.Workspace)
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
// Verify project belongs to workspace if specified
if (req.ProjectId.HasValue)
{
var projectExists = await db.Projects
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
if (!projectExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
return;
}
}
// Update fields
if (!string.IsNullOrEmpty(req.DestinationUrl))
{
link.DestinationUrl = req.DestinationUrl;
}
if (req.Title != null)
{
link.Title = req.Title;
}
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
{
link.Status = status;
}
if (req.ExpiresAt.HasValue)
{
link.ExpiresAt = req.ExpiresAt.Value;
}
if (!string.IsNullOrEmpty(req.Password))
{
link.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password);
}
else if (req.RemovePassword == true)
{
link.PasswordHash = null;
}
if (req.ProjectId.HasValue)
{
link.ProjectId = req.ProjectId.Value;
}
else if (req.RemoveProject == true)
{
link.ProjectId = null;
}
link.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var response = new LinkResponse(
link.Id,
link.WorkspaceId,
link.ProjectId,
link.DomainId,
link.Slug,
link.DestinationUrl,
link.Title,
link.Status.ToString(),
link.ExpiresAt,
link.PasswordHash != null,
link.CreatedAt,
link.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -65,8 +65,6 @@ public class CreateProjectEndpoint(AppDbContext db)
project.CreatedAt project.CreatedAt
); );
await Send.CreatedAtAsync(response, cancellation: ct);
await HttpContext.Response.SendAsync(response, 201, cancellation: ct); await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
} }
} }

View File

@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace api.Features.QRCodes.Common;
/// <summary>
/// QR code style configuration stored as JSON
/// </summary>
public class QRCodeStyle
{
/// <summary>Foreground color in hex format (e.g., "#000000")</summary>
public string ForegroundColor { get; set; } = "#000000";
/// <summary>Background color in hex format (e.g., "#FFFFFF")</summary>
public string BackgroundColor { get; set; } = "#FFFFFF";
/// <summary>Error correction level: L (7%), M (15%), Q (25%), H (30%)</summary>
public string ErrorCorrectionLevel { get; set; } = "M";
/// <summary>Quiet zone (margin) in modules</summary>
public int QuietZone { get; set; } = 4;
/// <summary>Module shape: Square, Circle, Rounded</summary>
public string ModuleShape { get; set; } = "Square";
/// <summary>Eye shape: Square, Circle, Rounded</summary>
public string EyeShape { get; set; } = "Square";
/// <summary>Pixels per module for rendering</summary>
public int PixelsPerModule { get; set; } = 20;
}
public record QRCodeResponse(
Guid Id,
Guid WorkspaceId,
Guid? ProjectId,
Guid? ShortLinkId,
string? ShortLinkSlug,
QRCodeStyle Style,
Guid? LogoAssetId,
DateTime CreatedAt,
DateTime UpdatedAt
);
public record QRCodeListResponse(
IEnumerable<QRCodeResponse> QRCodes
);
public record QRCodePreviewResponse(
string DataUrl,
string Format,
int Width,
int Height
);

View File

@@ -0,0 +1,114 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class CreateQRCodeRequest
{
public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ShortLinkId { get; set; }
public QRCodeStyle? Style { get; set; }
}
public class CreateQRCodeValidator : Validator<CreateQRCodeRequest>
{
public CreateQRCodeValidator()
{
RuleFor(x => x.ShortLinkId)
.NotEmpty().WithMessage("ShortLinkId is required");
}
}
public class CreateQRCodeEndpoint(AppDbContext db)
: Endpoint<CreateQRCodeRequest, QRCodeResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/qrcodes");
}
public override async Task HandleAsync(CreateQRCodeRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Verify short link belongs to workspace
string? linkSlug = null;
if (req.ShortLinkId.HasValue)
{
var link = await db.ShortLinks
.Where(l => l.Id == req.ShortLinkId.Value && l.WorkspaceId == req.WorkspaceId)
.Select(l => new { l.Slug })
.FirstOrDefaultAsync(ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Short link not found"), 404, cancellation: ct);
return;
}
linkSlug = link.Slug;
}
// Verify project belongs to workspace if specified
if (req.ProjectId.HasValue)
{
var projectExists = await db.Projects
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
if (!projectExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
return;
}
}
var style = req.Style ?? new QRCodeStyle();
var now = DateTime.UtcNow;
var qrCode = new QRCodeDesign
{
Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId,
ProjectId = req.ProjectId,
ShortLinkId = req.ShortLinkId,
StyleJson = JsonSerializer.Serialize(style),
LogoAssetId = null,
CreatedAt = now,
UpdatedAt = now
};
db.QrCodeDesigns.Add(qrCode);
await db.SaveChangesAsync(ct);
var response = new QRCodeResponse(
qrCode.Id,
qrCode.WorkspaceId,
qrCode.ProjectId,
qrCode.ShortLinkId,
linkSlug,
style,
qrCode.LogoAssetId,
qrCode.CreatedAt,
qrCode.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
}

View File

@@ -0,0 +1,42 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class DeleteQRCodeRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class DeleteQRCodeEndpoint(AppDbContext db)
: Endpoint<DeleteQRCodeRequest>
{
public override void Configure()
{
Delete("/workspaces/{WorkspaceId}/qrcodes/{Id}");
}
public override async Task HandleAsync(DeleteQRCodeRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var qrCode = await db.QrCodeDesigns
.Include(q => q.Workspace)
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
if (qrCode is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
return;
}
db.QrCodeDesigns.Remove(qrCode);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("QR code deleted"), 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,74 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class ExportQRCodeRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string? Format { get; set; } // png or svg
public int? Size { get; set; }
}
public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator)
: Endpoint<ExportQRCodeRequest>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/export");
}
public override async Task HandleAsync(ExportQRCodeRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
if (qrCode is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
return;
}
if (qrCode.ShortLink is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
return;
}
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var format = (req.Format ?? "png").ToLowerInvariant();
var size = req.Size ?? 512;
// Build the short link URL
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
var filename = $"qrcode-{qrCode.ShortLink.Slug}";
if (format == "svg")
{
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);
HttpContext.Response.ContentType = "image/png";
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\"";
await HttpContext.Response.Body.WriteAsync(png, ct);
}
}
}

View File

@@ -0,0 +1,56 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class GetQRCodeRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class GetQRCodeEndpoint(AppDbContext db)
: Endpoint<GetQRCodeRequest, QRCodeResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}");
}
public override async Task HandleAsync(GetQRCodeRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
if (qrCode is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
return;
}
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var response = new QRCodeResponse(
qrCode.Id,
qrCode.WorkspaceId,
qrCode.ProjectId,
qrCode.ShortLinkId,
qrCode.ShortLink?.Slug,
style,
qrCode.LogoAssetId,
qrCode.CreatedAt,
qrCode.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,74 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class ListQRCodesRequest
{
public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ShortLinkId { get; set; }
}
public class ListQRCodesEndpoint(AppDbContext db)
: Endpoint<ListQRCodesRequest, QRCodeListResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/qrcodes");
}
public override async Task HandleAsync(ListQRCodesRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var query = db.QrCodeDesigns
.Where(q => q.WorkspaceId == req.WorkspaceId);
if (req.ProjectId.HasValue)
{
query = query.Where(q => q.ProjectId == req.ProjectId.Value);
}
if (req.ShortLinkId.HasValue)
{
query = query.Where(q => q.ShortLinkId == req.ShortLinkId.Value);
}
var qrCodes = await query
.Include(q => q.ShortLink)
.OrderByDescending(q => q.CreatedAt)
.ToListAsync(ct);
var response = new QRCodeListResponse(
qrCodes.Select(q => new QRCodeResponse(
q.Id,
q.WorkspaceId,
q.ProjectId,
q.ShortLinkId,
q.ShortLink?.Slug,
JsonSerializer.Deserialize<QRCodeStyle>(q.StyleJson) ?? new QRCodeStyle(),
q.LogoAssetId,
q.CreatedAt,
q.UpdatedAt
))
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,67 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class PreviewQRCodeRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public int? Size { get; set; }
}
public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator)
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/preview");
}
public override async Task HandleAsync(PreviewQRCodeRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
if (qrCode is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
return;
}
if (qrCode.ShortLink is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code has no associated link"), 400, cancellation: ct);
return;
}
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var size = req.Size ?? 256;
// Build the short link URL
// TODO: Use actual domain configuration
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size);
var response = new QRCodePreviewResponse(
DataUrl: dataUrl,
Format: "png",
Width: size,
Height: size
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,85 @@
using System.Security.Claims;
using System.Text.Json;
using api.Data;
using api.Features.Auth.Common;
using api.Features.QRCodes.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class UpdateQRCodeRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public Guid? ProjectId { get; set; }
public bool? RemoveProject { get; set; }
public QRCodeStyle? Style { get; set; }
}
public class UpdateQRCodeEndpoint(AppDbContext db)
: Endpoint<UpdateQRCodeRequest, QRCodeResponse>
{
public override void Configure()
{
Put("/workspaces/{WorkspaceId}/qrcodes/{Id}");
}
public override async Task HandleAsync(UpdateQRCodeRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var qrCode = await db.QrCodeDesigns
.Include(q => q.Workspace)
.Include(q => q.ShortLink)
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
if (qrCode is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
return;
}
// Verify project belongs to workspace if specified
if (req.ProjectId.HasValue)
{
var projectExists = await db.Projects
.AnyAsync(p => p.Id == req.ProjectId.Value && p.WorkspaceId == req.WorkspaceId, ct);
if (!projectExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct);
return;
}
qrCode.ProjectId = req.ProjectId.Value;
}
else if (req.RemoveProject == true)
{
qrCode.ProjectId = null;
}
if (req.Style != null)
{
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
}
qrCode.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var response = new QRCodeResponse(
qrCode.Id,
qrCode.WorkspaceId,
qrCode.ProjectId,
qrCode.ShortLinkId,
qrCode.ShortLink?.Slug,
style,
qrCode.LogoAssetId,
qrCode.CreatedAt,
qrCode.UpdatedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,94 @@
using System.Drawing;
using api.Features.QRCodes.Common;
using QRCoder;
namespace api.Features.QRCodes.Services;
public interface IQRCodeGeneratorService
{
byte[] GeneratePng(string content, QRCodeStyle style, int size = 512);
string GenerateSvg(string content, QRCodeStyle style, int size = 512);
string GenerateDataUrl(string content, QRCodeStyle style, int size = 256);
}
public class QRCodeGeneratorService : IQRCodeGeneratorService
{
public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512)
{
using var qrGenerator = new QRCodeGenerator();
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
using var qrCode = new PngByteQRCode(qrCodeData);
var foreground = ParseColor(style.ForegroundColor);
var background = ParseColor(style.BackgroundColor);
// Calculate pixels per module based on desired size
var moduleCount = qrCodeData.ModuleMatrix.Count;
var pixelsPerModule = Math.Max(1, size / moduleCount);
return qrCode.GetGraphic(pixelsPerModule, foreground, background, drawQuietZones: style.QuietZone > 0);
}
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
{
using var qrGenerator = new QRCodeGenerator();
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
using var qrCode = new SvgQRCode(qrCodeData);
var foreground = style.ForegroundColor;
var background = style.BackgroundColor;
// Calculate pixels per module
var moduleCount = qrCodeData.ModuleMatrix.Count;
var pixelsPerModule = Math.Max(1, size / moduleCount);
return qrCode.GetGraphic(
pixelsPerModule,
foreground,
background,
drawQuietZones: style.QuietZone > 0
);
}
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256)
{
var pngBytes = GeneratePng(content, style, size);
var base64 = Convert.ToBase64String(pngBytes);
return $"data:image/png;base64,{base64}";
}
private static QRCodeGenerator.ECCLevel ParseEccLevel(string level)
{
return level.ToUpperInvariant() switch
{
"L" => QRCodeGenerator.ECCLevel.L,
"M" => QRCodeGenerator.ECCLevel.M,
"Q" => QRCodeGenerator.ECCLevel.Q,
"H" => QRCodeGenerator.ECCLevel.H,
_ => QRCodeGenerator.ECCLevel.M
};
}
private static byte[] ParseColor(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)
];
}
// Default to black
return [0, 0, 0];
}
}

View File

@@ -0,0 +1,104 @@
using api.Data;
using api.Features.Auth.Common;
using api.Features.Events.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Redirect.Endpoints;
public class PasswordRedirectRequest
{
public string Slug { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class PasswordRedirectResponse
{
public string Location { get; set; } = string.Empty;
}
public class PasswordRedirectValidator : Validator<PasswordRedirectRequest>
{
public PasswordRedirectValidator()
{
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required");
}
}
public class PasswordRedirectEndpoint(AppDbContext db, IEventTrackingService eventTracking)
: Endpoint<PasswordRedirectRequest, PasswordRedirectResponse>
{
public override void Configure()
{
Post("/{Slug}");
AllowAnonymous();
}
public override async Task HandleAsync(PasswordRedirectRequest req, CancellationToken ct)
{
// Look up the short link by slug
var link = await db.ShortLinks
.Where(l => l.Slug == req.Slug && l.DomainId == null)
.Select(l => new
{
l.Id,
l.DestinationUrl,
l.Status,
l.ExpiresAt,
l.PasswordHash,
l.WorkspaceId
})
.FirstOrDefaultAsync(ct);
// Link not found
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
// Link is disabled
if (link.Status == ShortLinkStatus.Disabled)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
// Link has expired
if (link.ExpiresAt.HasValue && link.ExpiresAt.Value < DateTime.UtcNow)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link has expired"), 410, cancellation: ct);
return;
}
// Link is not password protected - just redirect
if (string.IsNullOrEmpty(link.PasswordHash))
{
// Track click event
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct);
return;
}
// Verify password
if (!BCrypt.Net.BCrypt.Verify(req.Password, link.PasswordHash))
{
await HttpContext.Response.SendAsync(new MessageResponse("Invalid password"), 401, cancellation: ct);
return;
}
// Track click event
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
// Password correct - redirect
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct);
}
}

View File

@@ -0,0 +1,84 @@
using api.Data;
using api.Features.Auth.Common;
using api.Features.Events.Services;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Redirect.Endpoints;
public class RedirectRequest
{
public string Slug { get; set; } = string.Empty;
}
public class RedirectResponse
{
public string Location { get; set; } = string.Empty;
}
public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracking)
: Endpoint<RedirectRequest, RedirectResponse>
{
public override void Configure()
{
Get("/{Slug}");
AllowAnonymous();
}
public override async Task HandleAsync(RedirectRequest req, CancellationToken ct)
{
// Look up the short link by slug (default domain = null for now)
var link = await db.ShortLinks
.Where(l => l.Slug == req.Slug && l.DomainId == null)
.Select(l => new
{
l.Id,
l.DestinationUrl,
l.Status,
l.ExpiresAt,
l.PasswordHash,
l.WorkspaceId
})
.FirstOrDefaultAsync(ct);
// Link not found
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
// Link is disabled
if (link.Status == ShortLinkStatus.Disabled)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link not found"), 404, cancellation: ct);
return;
}
// Link has expired
if (link.ExpiresAt.HasValue && link.ExpiresAt.Value < DateTime.UtcNow)
{
await HttpContext.Response.SendAsync(new MessageResponse("Link has expired"), 410, cancellation: ct);
return;
}
// Link is password protected
if (!string.IsNullOrEmpty(link.PasswordHash))
{
// Return 401 with a hint that password is required
// Frontend/client would need to POST the password to verify
HttpContext.Response.Headers["X-Password-Required"] = "true";
await HttpContext.Response.SendAsync(new MessageResponse("Password required"), 401, cancellation: ct);
return;
}
// Track click event asynchronously (fire and forget)
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
// Redirect to destination (302 Found)
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
HttpContext.Response.Headers.Location = link.DestinationUrl;
await HttpContext.Response.StartAsync(ct);
}
}

View File

@@ -1,6 +1,8 @@
using System.Text; using System.Text;
using api.Data; using api.Data;
using api.Features.Auth.Settings; using api.Features.Auth.Settings;
using api.Features.Events.Services;
using api.Features.QRCodes.Services;
using FastEndpoints; using FastEndpoints;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,6 +14,10 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"))); options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
// Register application services
builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>();
builder.Services.AddSingleton<IQRCodeGeneratorService, QRCodeGeneratorService>();
// Configure JWT settings // Configure JWT settings
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt")); builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!; var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;

View File

@@ -20,6 +20,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="QRCoder" Version="1.7.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.0" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.0" />
</ItemGroup> </ItemGroup>