feat: comprehensive app improvements with Pinia state management

Backend:
- Add API keys management (create, list, delete endpoints)
- Add email verification flow (verify, resend verification)
- Add account management (profile, change password, delete account)
- Add billing/Stripe integration (checkout, portal, webhooks)
- Add GeoIP service for analytics
- Add bulk link creation and link restore endpoints
- Add QR code analytics endpoint
- Add project description field with migration
- Add QR code name and logo support with migration
- Improve QR code generator with logo overlay support
- Add rate limiting middleware
- Update tests for new functionality

Frontend:
- Refactor entire app to use Pinia for state management
- Add auth store with initialization, login, register, logout
- Add workspace store with CRUD for workspaces, projects, links,
  QR codes, domains, assets, and analytics
- Add localStorage persistence for workspace selection
- Update App.vue with proper store initialization
- Update AppLayout.vue to use store methods instead of direct API
- Refactor Projects.vue and Domains.vue to use store state/actions
- Add VerifyEmail.vue for email verification flow
- Add ForgotPassword.vue and ResetPassword.vue
- Add Settings.vue with profile, password, API keys, danger zone
- Add QRCodeDetail.vue for QR code analytics
- Add Billing.vue for subscription management
- Expand api/client.js with all new API methods
- Add workspace change watchers for automatic data refresh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 18:53:03 -05:00
parent abf7968911
commit e7d96f5508
100 changed files with 11424 additions and 254 deletions

View File

@@ -175,13 +175,13 @@
--- ---
## Phase 6: Frontend Dashboard (In Progress) ## Phase 6: Frontend Dashboard (Complete)
### Authentication UI ### Authentication UI
- [x] Login page - [x] Login page
- [x] Registration page - [x] Registration page
- [ ] Forgot password page - [x] Forgot password page
- [ ] Password reset page - [x] Password reset page
- [x] Auth state management (Pinia store) - [x] Auth state management (Pinia store)
### Dashboard ### Dashboard
@@ -198,17 +198,17 @@
### QR Designer UI ### QR Designer UI
- [x] QR designer page - [x] QR designer page
- [x] Color pickers - [x] Color pickers
- [~] Shape selectors (basic support) - [x] Shape selectors (Square, Rounded, Dots for modules; Square, Rounded, Circle for eyes)
- [ ] Logo upload integration - [x] Logo upload integration (upload new or select from existing assets)
- [x] Live preview (for saved QR codes) - [x] Live preview (for saved QR codes)
- [x] Export buttons (PNG/SVG) - [x] Export buttons (PNG/SVG)
- [x] Style presets (6 presets) - [x] Style presets (6 presets with shape variations)
### Analytics UI ### Analytics UI
- [x] Charts (time series with clicks/scans) - [x] Charts (time series with clicks/scans)
- [x] Stat cards (clicks, scans, visitors, total) - [x] Stat cards (clicks, scans, visitors, total)
- [x] Breakdown tables (referrer, device) - [x] Breakdown tables (referrer, device)
- [~] Geo breakdown (API ready, UI pending) - [x] Geo breakdown (country flags and names, requires MaxMind GeoIP2 database)
--- ---
@@ -230,8 +230,7 @@
### Plan & Quotas ### Plan & Quotas
- [ ] Usage tracking - [ ] Usage tracking
- [ ] Plan limits enforcement - [ ] Plan limits enforcement
- Free: 50 links, 1 workspace - Free: 50 links, 1 workspacf - Pro: 5,000 links, 5 workspaces
- Pro: 5,000 links, 5 workspaces
- Business: Unlimited - Business: Unlimited
- [ ] Upgrade prompts - [ ] Upgrade prompts
@@ -297,6 +296,131 @@ Completed:
--- ---
## Gap Analysis (Spec vs Implementation)
> This section identifies gaps between the MVP spec (`docs/spec.md`) and the current implementation.
### Authentication & Account
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Email verification | ❌ Missing | Endpoint structure exists, but no email sending or verification flow |
| Basic account settings page | ❌ Missing | No settings UI or endpoints for profile updates |
| SSO (optional, post-MVP) | ⏳ Deferred | As expected |
### Short Link Features
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| UTM builder (preset templates) | ❌ Missing | Spec mentions UTM builder for Pro plan |
| Destination URL allowlist/denylist | ❌ Missing | Abuse prevention not implemented |
| Soft delete for links | ❌ Missing | Currently using hard delete |
### QR Code Designer
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Shape presets (module shapes) | ✅ Complete | Square, Rounded, Dots module shapes supported |
| Eye shape customization | ✅ Complete | Square, Rounded, Circle eye shapes supported |
| Logo upload integration | ✅ Complete | Upload new or select from existing assets |
| Logo size + margin controls | ⚠️ Partial | Fixed 20% size, no user controls |
| Print-ready options ("high contrast" toggle) | ❌ Missing | No print optimization features |
### Analytics & Tracking
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Geo (country) breakdown | ✅ Complete | MaxMind GeoIP2 integrated, UI with country flags |
| Per-QR analytics endpoint | ❌ Missing | Spec: `GET /analytics/qrcode/{id}` - only link analytics exist |
| Scan vs Click distinction via `?qr=` param | ⚠️ Partial | Event type exists but QR export doesn't append `?qr=<id>` to URLs |
| Custom date range filter | ❌ Missing | Only 24h/7d/30d implemented, spec mentions custom range |
| Monthly IP salt rotation | ❌ Missing | Spec requires rotating salt for privacy compliance |
| Event retention configuration per plan | ❌ Missing | No retention policy or cleanup jobs |
### Admin & Quotas
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Subscription status display | ❌ Missing | Plan field exists on Workspace but no UI |
| Usage quotas enforcement | ❌ Missing | No limits enforced for links/QRs/events/domains |
| Upgrade prompts | ❌ Missing | No paywall or upgrade flows |
### Security & Non-Functional
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Rate limiting on public endpoints | ❌ Missing | Critical for redirect endpoint |
| CORS configuration | ❌ Missing | Needs proper configuration |
| Strict CSP headers | ❌ Missing | App pages have no CSP |
| Request logging | ❌ Missing | No structured logging |
| Error handling middleware | ❌ Missing | No global error handler |
### Frontend UI Pages
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Forgot password page | ✅ Complete | Full UI with success state |
| Password reset page | ✅ Complete | Full UI with token validation and success state |
| Projects list UI | ❌ Missing | Backend CRUD complete, no frontend |
| Domains page (add/verify) | ❌ Missing | Backend complete, no frontend |
| Workspace switcher (full UI) | ⚠️ Partial | Basic switcher exists, no create/manage UI |
| Per-QR analytics view | ❌ Missing | Only per-link analytics in UI |
### Email System
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Email service integration | ❌ Missing | No email provider configured |
| Email verification emails | ❌ Missing | No templates or sending logic |
| Password reset emails | ❌ Missing | Token generated but not emailed |
| Email templates | ❌ Missing | No templating system |
### Background Jobs
| Spec Requirement | Status | Notes |
|-----------------|--------|-------|
| Domain verification checks | ❌ Missing | Only manual verification, no periodic checks |
| Event enrichment (geo/device) | ✅ Complete | Device parsing and GeoIP country lookup done |
| Cleanup & retention tasks | ❌ Missing | No scheduled cleanup for old events |
### API Surface Gaps
| Endpoint (from spec) | Status |
|---------------------|--------|
| `GET /analytics/qrcode/{id}` | ❌ Missing |
| Account settings endpoints | ❌ Missing |
| Usage/quota endpoints | ❌ Missing |
---
## Priority Gap Resolution
### High Priority (MVP Blockers)
1. **Email system** - Verification and password reset cannot work without email
2. **Rate limiting** - Security risk without it on public redirect
3. **QR scan tracking** - QR exports need `?qr=<id>` param for scan attribution
4. ~~**Geo breakdown** - GeoIP integration for country-level analytics~~ ✅ Complete
5. **Projects UI** - Backend exists, needs frontend
### Medium Priority (MVP Polish)
6. **Account settings page** - Users need to update profile
7. **Domains UI** - Backend exists, needs frontend
8. **Usage quotas** - Enforce plan limits
9. ~~**QR shape presets** - More customization options~~ ✅ Complete (Square, Rounded, Dots)
10. **Custom date range** - Analytics flexibility
### Lower Priority (Post-MVP)
11. UTM builder
12. Soft delete for links
13. Print-ready QR options
14. SSO integration
15. Stripe payments
---
## Notes ## Notes
- Backend uses FastEndpoints (not traditional MVC controllers) - Backend uses FastEndpoints (not traditional MVC controllers)

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;
@@ -13,8 +14,36 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest") private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
.Build(); .Build();
private bool _containerStarted = false;
private void EnsureContainerStarted()
{
if (!_containerStarted)
{
_postgres.StartAsync().GetAwaiter().GetResult();
_containerStarted = true;
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
// Ensure container is started before we need the connection string
EnsureContainerStarted();
builder.UseEnvironment("Testing");
// Set environment variables for configuration (these take precedence)
Environment.SetEnvironmentVariable("Jwt__Secret", "test-secret-key-min-32-characters-long-for-hmac256!");
Environment.SetEnvironmentVariable("Jwt__Issuer", "TrakQR");
Environment.SetEnvironmentVariable("Jwt__Audience", "TrakQR");
Environment.SetEnvironmentVariable("Jwt__ExpirationMinutes", "60");
Environment.SetEnvironmentVariable("Email__Provider", "console");
Environment.SetEnvironmentVariable("Stripe__SecretKey", "sk_test_fake_key");
Environment.SetEnvironmentVariable("Stripe__WebhookSecret", "whsec_fake_secret");
Environment.SetEnvironmentVariable("Stripe__ProPriceId", "price_test_pro");
Environment.SetEnvironmentVariable("Stripe__BusinessPriceId", "price_test_business");
Environment.SetEnvironmentVariable("ConnectionStrings__PostgresConnection", _postgres.GetConnectionString());
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
// Remove existing DbContext registration // Remove existing DbContext registration
@@ -29,12 +58,14 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
// Add DbContext with Testcontainers connection string // Add DbContext with Testcontainers connection string
services.AddDbContext<AppDbContext>(options => services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString())); options.UseNpgsql(_postgres.GetConnectionString()));
}); });
} }
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
await _postgres.StartAsync(); // Ensure container is started (might already be started from ConfigureWebHost)
EnsureContainerStarted();
// Run migrations // Run migrations
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
@@ -47,4 +78,19 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
await _postgres.DisposeAsync(); await _postgres.DisposeAsync();
await base.DisposeAsync(); await base.DisposeAsync();
} }
/// <summary>
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
/// </summary>
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces.FindAsync(workspaceId);
if (workspace != null)
{
workspace.Plan = api.Models.WorkspacePlan.Pro;
await db.SaveChangesAsync();
}
}
} }

View File

@@ -168,7 +168,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
} }
[Fact] [Fact]
public async Task ResetPassword_ReturnsNotImplemented() public async Task ResetPassword_WithInvalidToken_ReturnsBadRequest()
{ {
// Arrange // Arrange
var request = new { Token = "some-token", NewPassword = "newpassword123" }; var request = new { Token = "some-token", NewPassword = "newpassword123" };
@@ -181,7 +181,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
var result = await response.Content.ReadFromJsonAsync<MessageResponse>(); var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.Message.Should().Be("Password reset is not yet available"); result!.Message.Should().Be("Invalid or expired reset token");
} }
[Fact] [Fact]

View File

@@ -13,7 +13,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email) private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = true)
{ {
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" }); var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict) if (response.StatusCode == HttpStatusCode.Conflict)
@@ -28,6 +28,12 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>(); var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id; var workspaceId = workspaces!.Workspaces.First().Id;
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
return (token, workspaceId); return (token, workspaceId);
} }

View File

@@ -12,7 +12,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
private async Task<string> GetAuthTokenAsync(string email = "workspace-test@example.com") private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = false)
{ {
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" }); var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict) if (response.StatusCode == HttpStatusCode.Conflict)
@@ -20,7 +20,25 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" }); response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
} }
var result = await response.Content.ReadFromJsonAsync<AuthResponse>(); var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
return result!.Token; var token = result!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var workspacesResponse = await _client.GetAsync("/workspaces");
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
if (upgradeToPro)
{
await factory.UpgradeWorkspaceToPro(workspaceId);
}
return (token, workspaceId);
}
private async Task<string> GetAuthTokenAsync(string email = "workspace-test@example.com")
{
var (token, _) = await GetAuthAndWorkspaceAsync(email);
return token;
} }
[Fact] [Fact]
@@ -53,8 +71,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact] [Fact]
public async Task CreateWorkspace_WithValidData_ReturnsCreated() public async Task CreateWorkspace_WithValidData_ReturnsCreated()
{ {
// Arrange // Arrange - upgrade to Pro to allow creating additional workspaces
var token = await GetAuthTokenAsync("create-ws@example.com"); var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", upgradeToPro: true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act // Act
@@ -86,21 +104,18 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact] [Fact]
public async Task GetWorkspace_WithValidId_ReturnsWorkspace() public async Task GetWorkspace_WithValidId_ReturnsWorkspace()
{ {
// Arrange // Arrange - use the default workspace (created on registration)
var token = await GetAuthTokenAsync("get-ws@example.com"); var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-ws@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Get Test" });
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
// Act // Act
var response = await _client.GetAsync($"/workspaces/{created!.Id}"); var response = await _client.GetAsync($"/workspaces/{workspaceId}");
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>(); var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>();
result!.Id.Should().Be(created.Id); result!.Id.Should().Be(workspaceId);
result.Name.Should().Be("Get Test"); result.Name.Should().NotBeNullOrEmpty();
} }
[Fact] [Fact]
@@ -120,15 +135,12 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact] [Fact]
public async Task UpdateWorkspace_WithValidData_ReturnsUpdated() public async Task UpdateWorkspace_WithValidData_ReturnsUpdated()
{ {
// Arrange // Arrange - use the default workspace (created on registration)
var token = await GetAuthTokenAsync("update-ws@example.com"); var (token, workspaceId) = await GetAuthAndWorkspaceAsync("update-ws@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Original Name" });
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
// Act // Act
var response = await _client.PutAsJsonAsync($"/workspaces/{created!.Id}", new { Name = "Updated Name" }); var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}", new { Name = "Updated Name" });
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -139,8 +151,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
[Fact] [Fact]
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess() public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
{ {
// Arrange // Arrange - upgrade to Pro to allow creating additional workspaces
var token = await GetAuthTokenAsync("delete-ws@example.com"); var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", upgradeToPro: true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" }); var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });

View File

@@ -14,6 +14,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<QRCodeDesign> QrCodeDesigns => Set<QRCodeDesign>(); public DbSet<QRCodeDesign> QrCodeDesigns => Set<QRCodeDesign>();
public DbSet<Event> Events => Set<Event>(); public DbSet<Event> Events => Set<Event>();
public DbSet<Asset> Assets => Set<Asset>(); public DbSet<Asset> Assets => Set<Asset>();
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
public DbSet<EmailVerificationToken> EmailVerificationTokens => Set<EmailVerificationToken>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -177,5 +180,49 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(e => e.WorkspaceId) .HasForeignKey(e => e.WorkspaceId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
// PasswordResetToken configuration
modelBuilder.Entity<PasswordResetToken>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Token).IsUnique();
entity.Property(e => e.Token).HasMaxLength(64);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
// EmailVerificationToken configuration
modelBuilder.Entity<EmailVerificationToken>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Token).IsUnique();
entity.Property(e => e.Token).HasMaxLength(64);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
// ApiKey configuration
modelBuilder.Entity<ApiKey>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.KeyHash).IsUnique();
entity.Property(e => e.Name).HasMaxLength(100);
entity.Property(e => e.KeyHash).HasMaxLength(64);
entity.Property(e => e.KeyPrefix).HasMaxLength(16);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasOne(e => e.Workspace)
.WithMany()
.HasForeignKey(e => e.WorkspaceId)
.OnDelete(DeleteBehavior.Cascade);
});
} }
} }

View File

@@ -25,7 +25,8 @@ public record WorkspaceAnalyticsResponse(
IEnumerable<TimeSeriesPoint> TimeSeries, IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> TopLinks, IEnumerable<BreakdownItem> TopLinks,
IEnumerable<BreakdownItem> DeviceBreakdown, IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
); );
public record LinkAnalyticsResponse( public record LinkAnalyticsResponse(
@@ -34,5 +35,6 @@ public record LinkAnalyticsResponse(
AnalyticsSummary Summary, AnalyticsSummary Summary,
IEnumerable<TimeSeriesPoint> TimeSeries, IEnumerable<TimeSeriesPoint> TimeSeries,
IEnumerable<BreakdownItem> DeviceBreakdown, IEnumerable<BreakdownItem> DeviceBreakdown,
IEnumerable<BreakdownItem> ReferrerBreakdown IEnumerable<BreakdownItem> ReferrerBreakdown,
IEnumerable<BreakdownItem> CountryBreakdown
); );

View File

@@ -13,6 +13,8 @@ public class LinkAnalyticsRequest
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid Id { get; set; } public Guid Id { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
public DateTime? StartDate { get; set; } // Custom date range start
public DateTime? EndDate { get; set; } // Custom date range end
} }
public class LinkAnalyticsEndpoint(AppDbContext db) public class LinkAnalyticsEndpoint(AppDbContext db)
@@ -39,8 +41,19 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
return; return;
} }
// Determine time filter // Determine time filter (custom range takes precedence over period)
var startDate = GetStartDate(req.Period); DateTime? startDate = null;
DateTime? endDate = null;
if (req.StartDate.HasValue && req.EndDate.HasValue)
{
startDate = req.StartDate.Value;
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
}
else
{
startDate = GetStartDate(req.Period);
}
// Query events for this link // Query events for this link
var eventsQuery = db.Events var eventsQuery = db.Events
@@ -51,6 +64,11 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value); eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
} }
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
}
var events = await eventsQuery.ToListAsync(ct); var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count; var totalEvents = events.Count;
@@ -99,13 +117,27 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
)) ))
.ToList(); .ToList();
// Country breakdown
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new LinkAnalyticsResponse( var response = new LinkAnalyticsResponse(
LinkId: link.Id, LinkId: link.Id,
Slug: link.Slug, Slug: link.Slug,
Summary: summary, Summary: summary,
TimeSeries: timeSeries, TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown, DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
); );
await HttpContext.Response.SendAsync(response, 200, cancellation: ct); await HttpContext.Response.SendAsync(response, 200, cancellation: ct);

View File

@@ -12,6 +12,8 @@ public class WorkspaceAnalyticsRequest
{ {
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
public DateTime? StartDate { get; set; } // Custom date range start
public DateTime? EndDate { get; set; } // Custom date range end
} }
public class WorkspaceAnalyticsEndpoint(AppDbContext db) public class WorkspaceAnalyticsEndpoint(AppDbContext db)
@@ -36,8 +38,19 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
return; return;
} }
// Determine time filter // Determine time filter (custom range takes precedence over period)
var startDate = GetStartDate(req.Period); DateTime? startDate = null;
DateTime? endDate = null;
if (req.StartDate.HasValue && req.EndDate.HasValue)
{
startDate = req.StartDate.Value;
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
}
else
{
startDate = GetStartDate(req.Period);
}
// Query events // Query events
var eventsQuery = db.Events var eventsQuery = db.Events
@@ -48,6 +61,11 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value); eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
} }
if (endDate.HasValue)
{
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
}
var events = await eventsQuery.ToListAsync(ct); var events = await eventsQuery.ToListAsync(ct);
var totalEvents = events.Count; var totalEvents = events.Count;
@@ -114,12 +132,26 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
)) ))
.ToList(); .ToList();
// Get country breakdown
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.Select(g => new BreakdownItem(
g.Key,
g.Count(),
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
))
.ToList();
var response = new WorkspaceAnalyticsResponse( var response = new WorkspaceAnalyticsResponse(
Summary: summary, Summary: summary,
TimeSeries: timeSeries, TimeSeries: timeSeries,
TopLinks: topLinks, TopLinks: topLinks,
DeviceBreakdown: deviceBreakdown, DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
); );
await HttpContext.Response.SendAsync(response, 200, cancellation: ct); await HttpContext.Response.SendAsync(response, 200, cancellation: ct);

View File

@@ -0,0 +1,102 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.ApiKeys.Endpoints;
public class CreateApiKeyRequest
{
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public DateTime? ExpiresAt { get; set; }
public List<string>? Scopes { get; set; }
}
public class CreateApiKeyResponse
{
public required Guid Id { get; set; }
public required string Name { get; set; }
public required string Key { get; set; } // Only returned once on creation!
public required string KeyPrefix { get; set; }
public List<string>? Scopes { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
}
public class CreateApiKeyEndpoint(AppDbContext db)
: Endpoint<CreateApiKeyRequest, CreateApiKeyResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/api-keys");
}
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Check API key limit (max 10 per workspace)
var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct);
if (existingCount >= 10)
{
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400, cancellation: ct);
return;
}
// Generate secure key: trk_<32 random bytes as base64url>
var randomBytes = RandomNumberGenerator.GetBytes(32);
var keyValue = "trk_" + Convert.ToBase64String(randomBytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
var keyHash = ComputeSha256Hash(keyValue);
var keyPrefix = keyValue[..12] + "...";
var apiKey = new ApiKey
{
Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId,
Name = req.Name,
KeyHash = keyHash,
KeyPrefix = keyPrefix,
Scopes = req.Scopes,
ExpiresAt = req.ExpiresAt,
CreatedAt = DateTime.UtcNow,
IsActive = true,
};
db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct);
var response = new CreateApiKeyResponse
{
Id = apiKey.Id,
Name = apiKey.Name,
Key = keyValue, // Only returned once!
KeyPrefix = keyPrefix,
Scopes = apiKey.Scopes,
ExpiresAt = apiKey.ExpiresAt,
CreatedAt = apiKey.CreatedAt,
};
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
private static string ComputeSha256Hash(string input)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLower();
}
}

View File

@@ -0,0 +1,51 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.ApiKeys.Endpoints;
public class DeleteApiKeyRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class DeleteApiKeyEndpoint(AppDbContext db)
: Endpoint<DeleteApiKeyRequest>
{
public override void Configure()
{
Delete("/workspaces/{WorkspaceId}/api-keys/{Id}");
}
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var apiKey = await db.ApiKeys
.FirstOrDefaultAsync(k => k.Id == req.Id && k.WorkspaceId == req.WorkspaceId, ct);
if (apiKey is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("API key not found"), 404, cancellation: ct);
return;
}
db.ApiKeys.Remove(apiKey);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,72 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.ApiKeys.Endpoints;
public class ListApiKeysRequest
{
public Guid WorkspaceId { get; set; }
}
public class ApiKeyDto
{
public required Guid Id { get; set; }
public required string Name { get; set; }
public required string KeyPrefix { get; set; }
public List<string>? Scopes { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
}
public class ListApiKeysResponse
{
public required List<ApiKeyDto> ApiKeys { get; set; }
}
public class ListApiKeysEndpoint(AppDbContext db)
: Endpoint<ListApiKeysRequest, ListApiKeysResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/api-keys");
}
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var apiKeys = await db.ApiKeys
.Where(k => k.WorkspaceId == req.WorkspaceId)
.OrderByDescending(k => k.CreatedAt)
.Select(k => new ApiKeyDto
{
Id = k.Id,
Name = k.Name,
KeyPrefix = k.KeyPrefix,
Scopes = k.Scopes,
ExpiresAt = k.ExpiresAt,
LastUsedAt = k.LastUsedAt,
CreatedAt = k.CreatedAt,
IsActive = k.IsActive,
})
.ToListAsync(ct);
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,59 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
namespace api.Features.Auth.Endpoints;
public class ChangePasswordRequest
{
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public class ChangePasswordValidator : Validator<ChangePasswordRequest>
{
public ChangePasswordValidator()
{
RuleFor(x => x.CurrentPassword)
.NotEmpty().WithMessage("Current password is required");
RuleFor(x => x.NewPassword)
.NotEmpty().WithMessage("New password is required")
.MinimumLength(8).WithMessage("New password must be at least 8 characters");
}
}
public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRequest>
{
public override void Configure()
{
Post("/auth/change-password");
}
public override async Task HandleAsync(ChangePasswordRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
// Verify current password
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
{
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400, cancellation: ct);
return;
}
// Update password
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Password changed successfully"), cancellation: ct);
}
}

View File

@@ -0,0 +1,59 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class DeleteAccountRequest
{
public string Password { get; set; } = string.Empty;
}
public class DeleteAccountValidator : Validator<DeleteAccountRequest>
{
public DeleteAccountValidator()
{
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required to delete account");
}
}
public class DeleteAccountEndpoint(AppDbContext db) : Endpoint<DeleteAccountRequest>
{
public override void Configure()
{
Delete("/auth/account");
}
public override async Task HandleAsync(DeleteAccountRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
// Verify password
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
{
await HttpContext.Response.SendAsync(new MessageResponse("Password is incorrect"), 400, cancellation: ct);
return;
}
// Delete all user's workspaces (cascade will handle related data)
var workspaces = await db.Workspaces.Where(w => w.OwnerUserId == userId).ToListAsync(ct);
db.Workspaces.RemoveRange(workspaces);
// Delete user
db.Users.Remove(user);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct);
}
}

View File

@@ -1,6 +1,8 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using api.Data; using api.Data;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -22,13 +24,14 @@ public class ForgotPasswordValidator : Validator<ForgotPasswordRequest>
} }
} }
public class ForgotPasswordEndpoint(AppDbContext db) public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
: Endpoint<ForgotPasswordRequest, MessageResponse> : Endpoint<ForgotPasswordRequest, MessageResponse>
{ {
public override void Configure() public override void Configure()
{ {
Post("/auth/forgot"); Post("/auth/forgot");
AllowAnonymous(); AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
} }
public override async Task HandleAsync(ForgotPasswordRequest req, CancellationToken ct) public override async Task HandleAsync(ForgotPasswordRequest req, CancellationToken ct)
@@ -36,20 +39,55 @@ public class ForgotPasswordEndpoint(AppDbContext db)
var normalizedEmail = req.Email.ToLowerInvariant(); var normalizedEmail = req.Email.ToLowerInvariant();
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct); var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct);
if (user == null) if (user != null)
{ {
Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail); // Invalidate any existing tokens for this user
var existingTokens = await db.PasswordResetTokens
.Where(t => t.UserId == user.Id && !t.Used)
.ToListAsync(ct);
foreach (var token in existingTokens)
{
token.Used = true;
}
// Generate new token
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
var passwordResetToken = new PasswordResetToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = resetToken,
ExpiresAt = DateTime.UtcNow.AddHours(1),
Used = false,
CreatedAt = DateTime.UtcNow
};
db.PasswordResetTokens.Add(passwordResetToken);
await db.SaveChangesAsync(ct);
// Send password reset email
try
{
await emailService.SendPasswordResetEmailAsync(normalizedEmail, resetToken, ct);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to send password reset email to {Email}", normalizedEmail);
// Don't fail the request - still return success to prevent email enumeration
}
} }
else else
{ {
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail);
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
// TODO: Store reset token in database with expiration
// TODO: Send email with reset link
Logger.LogInformation("Password reset token generated for: {Email}, Token: {Token}", normalizedEmail, resetToken);
} }
// Always return success to prevent email enumeration // Always return success to prevent email enumeration
await HttpContext.Response.SendAsync(new MessageResponse("If the email exists, a reset link will be sent"), 200, cancellation: ct); await HttpContext.Response.SendAsync(
new MessageResponse("If the email exists, a reset link will be sent"),
200,
cancellation: ct);
} }
} }

View File

@@ -0,0 +1,44 @@
using System.Security.Claims;
using api.Data;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public record ProfileResponse(
Guid Id,
string Email,
bool IsVerified,
DateTime CreatedAt
);
public class GetProfileEndpoint(AppDbContext db) : EndpointWithoutRequest<ProfileResponse>
{
public override void Configure()
{
Get("/auth/profile");
}
public override async Task HandleAsync(CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users
.Where(u => u.Id == userId)
.Select(u => new ProfileResponse(
u.Id,
u.Email,
u.VerifiedAt != null,
u.CreatedAt
))
.FirstOrDefaultAsync(ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new { message = "User not found" }, 404, cancellation: ct);
return;
}
await HttpContext.Response.SendAsync(user, cancellation: ct);
}
}

View File

@@ -40,6 +40,7 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
{ {
Post("/auth/login"); Post("/auth/login");
AllowAnonymous(); AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
} }
public override async Task HandleAsync(LoginRequest req, CancellationToken ct) public override async Task HandleAsync(LoginRequest req, CancellationToken ct)

View File

@@ -1,9 +1,11 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text; using System.Text;
using api.Data; using api.Data;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using api.Features.Auth.Settings; using api.Features.Auth.Settings;
using api.Features.Email.Services;
using api.Models; using api.Models;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
@@ -35,7 +37,7 @@ public class RegisterValidator : Validator<RegisterRequest>
} }
} }
public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings) public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings, IEmailService emailService)
: Endpoint<RegisterRequest, AuthResponse> : Endpoint<RegisterRequest, AuthResponse>
{ {
private readonly JwtSettings _jwtSettings = jwtSettings.Value; private readonly JwtSettings _jwtSettings = jwtSettings.Value;
@@ -44,6 +46,7 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
{ {
Post("/auth/register"); Post("/auth/register");
AllowAnonymous(); AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
} }
public override async Task HandleAsync(RegisterRequest req, CancellationToken ct) public override async Task HandleAsync(RegisterRequest req, CancellationToken ct)
@@ -76,8 +79,29 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
}; };
db.Workspaces.Add(workspace); db.Workspaces.Add(workspace);
// Create email verification token
var tokenBytes = RandomNumberGenerator.GetBytes(32);
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
var verificationToken = new EmailVerificationToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = tokenString,
ExpiresAt = DateTime.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow
};
db.EmailVerificationTokens.Add(verificationToken);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// Send verification email (fire and forget)
_ = emailService.SendEmailVerificationAsync(normalizedEmail, tokenString, ct);
// Send welcome email
_ = emailService.SendWelcomeEmailAsync(normalizedEmail, normalizedEmail.Split('@')[0], ct);
Logger.LogInformation("User registered: {Email}", normalizedEmail); Logger.LogInformation("User registered: {Email}", normalizedEmail);
var response = GenerateAuthResponse(user); var response = GenerateAuthResponse(user);

View File

@@ -0,0 +1,63 @@
using System.Security.Claims;
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Email.Services;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
{
public override void Configure()
{
Post("/auth/resend-verification");
}
public override async Task HandleAsync(CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
if (user.VerifiedAt != null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400, cancellation: ct);
return;
}
// Remove existing tokens
var existingTokens = await db.EmailVerificationTokens
.Where(t => t.UserId == userId)
.ToListAsync(ct);
db.EmailVerificationTokens.RemoveRange(existingTokens);
// Create new token
var tokenBytes = RandomNumberGenerator.GetBytes(32);
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
var verificationToken = new EmailVerificationToken
{
Id = Guid.NewGuid(),
UserId = userId,
Token = tokenString,
ExpiresAt = DateTime.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow
};
db.EmailVerificationTokens.Add(verificationToken);
await db.SaveChangesAsync(ct);
// Send verification email
await emailService.SendEmailVerificationAsync(user.Email, tokenString, ct);
await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct);
}
}

View File

@@ -1,6 +1,8 @@
using api.Data;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints; namespace api.Features.Auth.Endpoints;
@@ -24,23 +26,63 @@ public class ValidatorResetPassword : Validator<ResetPasswordRequest>
} }
} }
public class ResetPasswordEndpoint : Endpoint<ResetPasswordRequest, MessageResponse> public class ResetPasswordEndpoint(AppDbContext db)
: Endpoint<ResetPasswordRequest, MessageResponse>
{ {
public override void Configure() public override void Configure()
{ {
Post("/auth/reset"); Post("/auth/reset");
AllowAnonymous(); AllowAnonymous();
Options(x => x.RequireRateLimiting("auth"));
} }
public override async Task HandleAsync(ResetPasswordRequest req, CancellationToken ct) public override async Task HandleAsync(ResetPasswordRequest req, CancellationToken ct)
{ {
// TODO: Implement password reset // Find the token
// 1. Look up token in database var resetToken = await db.PasswordResetTokens
// 2. Verify token hasn't expired .Include(t => t.User)
// 3. Get associated user .FirstOrDefaultAsync(t => t.Token == req.Token, ct);
// 4. Update password
// 5. Invalidate token
await HttpContext.Response.SendAsync(new MessageResponse("Password reset is not yet available"), 400, cancellation: ct); if (resetToken == null)
{
await HttpContext.Response.SendAsync(
new MessageResponse("Invalid or expired reset token"),
400,
cancellation: ct);
return;
}
// Check if token is expired
if (resetToken.ExpiresAt < DateTime.UtcNow)
{
await HttpContext.Response.SendAsync(
new MessageResponse("Reset token has expired"),
400,
cancellation: ct);
return;
}
// Check if token is already used
if (resetToken.Used)
{
await HttpContext.Response.SendAsync(
new MessageResponse("Reset token has already been used"),
400,
cancellation: ct);
return;
}
// Update the user's password
resetToken.User.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
resetToken.Used = true;
await db.SaveChangesAsync(ct);
Logger.LogInformation("Password reset successful for user: {Email}", resetToken.User.Email);
await HttpContext.Response.SendAsync(
new MessageResponse("Password has been reset successfully"),
200,
cancellation: ct);
} }
} }

View File

@@ -0,0 +1,68 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class UpdateProfileRequest
{
public string? Email { get; set; }
}
public class UpdateProfileValidator : Validator<UpdateProfileRequest>
{
public UpdateProfileValidator()
{
RuleFor(x => x.Email)
.EmailAddress().WithMessage("Invalid email address")
.When(x => !string.IsNullOrEmpty(x.Email));
}
}
public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequest, ProfileResponse>
{
public override void Configure()
{
Put("/auth/profile");
}
public override async Task HandleAsync(UpdateProfileRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await db.Users.FindAsync([userId], ct);
if (user == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
return;
}
if (!string.IsNullOrEmpty(req.Email) && req.Email != user.Email)
{
// Check if email is already taken
var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
if (emailExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409, cancellation: ct);
return;
}
user.Email = req.Email;
user.VerifiedAt = null; // Reset verification when email changes
}
await db.SaveChangesAsync(ct);
var response = new ProfileResponse(
user.Id,
user.Email,
user.VerifiedAt != null,
user.CreatedAt
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}

View File

@@ -0,0 +1,59 @@
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class VerifyEmailRequest
{
public string Token { get; set; } = string.Empty;
}
public class VerifyEmailValidator : Validator<VerifyEmailRequest>
{
public VerifyEmailValidator()
{
RuleFor(x => x.Token).NotEmpty().WithMessage("Token is required");
}
}
public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
{
public override void Configure()
{
Post("/auth/verify-email");
AllowAnonymous();
}
public override async Task HandleAsync(VerifyEmailRequest req, CancellationToken ct)
{
var token = await db.EmailVerificationTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == req.Token, ct);
if (token == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400, cancellation: ct);
return;
}
if (token.ExpiresAt < DateTime.UtcNow)
{
db.EmailVerificationTokens.Remove(token);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400, cancellation: ct);
return;
}
// Mark user as verified
token.User.VerifiedAt = DateTime.UtcNow;
// Remove the token
db.EmailVerificationTokens.Remove(token);
await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct);
}
}

View File

@@ -0,0 +1,23 @@
namespace api.Features.Billing.Common;
public record CheckoutSessionRequest(
Guid WorkspaceId,
string Plan,
string SuccessUrl,
string CancelUrl
);
public record CheckoutSessionResponse(string Url);
public record PortalSessionRequest(string ReturnUrl);
public record PortalSessionResponse(string Url);
public record SubscriptionResponse(
Guid WorkspaceId,
string Plan,
string? SubscriptionId,
DateTime? CurrentPeriodEnd,
bool IsActive,
bool CancelAtPeriodEnd
);

View File

@@ -0,0 +1,87 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Billing.Endpoints;
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
{
public CreateCheckoutSessionValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Plan)
.NotEmpty()
.Must(p => p == "Pro" || p == "Business")
.WithMessage("Plan must be 'Pro' or 'Business'");
RuleFor(x => x.SuccessUrl).NotEmpty().Must(BeValidUrl);
RuleFor(x => x.CancelUrl).NotEmpty().Must(BeValidUrl);
}
private static bool BeValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
}
public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService stripeService)
: Endpoint<CheckoutSessionRequest, CheckoutSessionResponse>
{
public override void Configure()
{
Post("/billing/checkout");
}
public override async Task HandleAsync(CheckoutSessionRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Check if already subscribed
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Workspace already has an active subscription. Use the billing portal to manage it."),
400,
cancellation: ct);
return;
}
var plan = Enum.Parse<WorkspacePlan>(req.Plan);
try
{
var checkoutUrl = await stripeService.CreateCheckoutSessionAsync(
userId,
req.WorkspaceId,
plan,
req.SuccessUrl,
req.CancelUrl,
ct);
await HttpContext.Response.SendAsync(new CheckoutSessionResponse(checkoutUrl), cancellation: ct);
}
catch (Exception ex)
{
await HttpContext.Response.SendAsync(
new MessageResponse($"Failed to create checkout session: {ex.Message}"),
500,
cancellation: ct);
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Security.Claims;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using FastEndpoints;
using FluentValidation;
namespace api.Features.Billing.Endpoints;
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
{
public CreatePortalSessionValidator()
{
RuleFor(x => x.ReturnUrl).NotEmpty().Must(BeValidUrl);
}
private static bool BeValidUrl(string url)
{
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
}
public class CreatePortalSessionEndpoint(IStripeService stripeService)
: Endpoint<PortalSessionRequest, PortalSessionResponse>
{
public override void Configure()
{
Post("/billing/portal");
}
public override async Task HandleAsync(PortalSessionRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
try
{
var portalUrl = await stripeService.CreateCustomerPortalSessionAsync(
userId,
req.ReturnUrl,
ct);
await HttpContext.Response.SendAsync(new PortalSessionResponse(portalUrl), cancellation: ct);
}
catch (InvalidOperationException ex)
{
await HttpContext.Response.SendAsync(
new MessageResponse(ex.Message),
400,
cancellation: ct);
}
catch (Exception ex)
{
await HttpContext.Response.SendAsync(
new MessageResponse($"Failed to create portal session: {ex.Message}"),
500,
cancellation: ct);
}
}
}

View File

@@ -0,0 +1,62 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Billing.Common;
using api.Features.Billing.Services;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Billing.Endpoints;
public class GetSubscriptionRequest
{
public Guid WorkspaceId { get; set; }
}
public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeService)
: Endpoint<GetSubscriptionRequest, SubscriptionResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/subscription");
}
public override async Task HandleAsync(GetSubscriptionRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var isActive = workspace.Plan != Models.WorkspacePlan.Free;
var cancelAtPeriodEnd = false;
// Get live subscription status from Stripe if exists
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
{
var subscription = await stripeService.GetSubscriptionAsync(workspace.StripeSubscriptionId, ct);
if (subscription != null)
{
isActive = subscription.Status == "active" || subscription.Status == "trialing";
cancelAtPeriodEnd = subscription.CancelAtPeriodEnd;
}
}
var response = new SubscriptionResponse(
workspace.Id,
workspace.Plan.ToString(),
workspace.StripeSubscriptionId,
workspace.SubscriptionEndsAt,
isActive,
cancelAtPeriodEnd
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}

View File

@@ -0,0 +1,87 @@
using api.Features.Billing.Services;
using api.Features.Billing.Settings;
using FastEndpoints;
using Microsoft.Extensions.Options;
using Stripe;
namespace api.Features.Billing.Endpoints;
public class StripeWebhookEndpoint(
IStripeService stripeService,
IOptions<StripeSettings> settings,
ILogger<StripeWebhookEndpoint> logger)
: EndpointWithoutRequest
{
private readonly StripeSettings _settings = settings.Value;
public override void Configure()
{
Post("/billing/webhook");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(ct);
try
{
var stripeSignature = HttpContext.Request.Headers["Stripe-Signature"].ToString();
var stripeEvent = EventUtility.ConstructEvent(
json,
stripeSignature,
_settings.WebhookSecret
);
logger.LogInformation("Received Stripe event: {EventType} ({EventId})", stripeEvent.Type, stripeEvent.Id);
switch (stripeEvent.Type)
{
case "checkout.session.completed":
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
if (session != null)
{
await stripeService.HandleCheckoutCompletedAsync(session, ct);
}
break;
case "customer.subscription.updated":
var updatedSubscription = stripeEvent.Data.Object as Subscription;
if (updatedSubscription != null)
{
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
}
break;
case "customer.subscription.deleted":
var deletedSubscription = stripeEvent.Data.Object as Subscription;
if (deletedSubscription != null)
{
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
}
break;
case "invoice.payment_failed":
logger.LogWarning("Payment failed for invoice: {InvoiceId}",
(stripeEvent.Data.Object as Invoice)?.Id);
break;
default:
logger.LogDebug("Unhandled Stripe event type: {EventType}", stripeEvent.Type);
break;
}
await HttpContext.Response.SendAsync(new { received = true }, cancellation: ct);
}
catch (StripeException ex)
{
logger.LogError(ex, "Stripe webhook signature verification failed");
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400, cancellation: ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing Stripe webhook");
await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct);
}
}
}

View File

@@ -0,0 +1,302 @@
using api.Data;
using api.Features.Billing.Settings;
using api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace api.Features.Billing.Services;
public interface IStripeService
{
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl, string cancelUrl, CancellationToken ct = default);
Task<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default);
Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default);
Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default);
string GetPriceIdForPlan(WorkspacePlan plan);
WorkspacePlan GetPlanForPriceId(string priceId);
}
public class StripeService : IStripeService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly StripeSettings _settings;
private readonly ILogger<StripeService> _logger;
public StripeService(
IServiceScopeFactory scopeFactory,
IOptions<StripeSettings> settings,
ILogger<StripeService> logger)
{
_scopeFactory = scopeFactory;
_settings = settings.Value;
_logger = logger;
StripeConfiguration.ApiKey = _settings.SecretKey;
}
public async Task<string> CreateCheckoutSessionAsync(
Guid userId,
Guid workspaceId,
WorkspacePlan plan,
string successUrl,
string cancelUrl,
CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct)
?? throw new InvalidOperationException("User not found");
// Get or create Stripe customer
var customerId = user.StripeCustomerId;
if (string.IsNullOrEmpty(customerId))
{
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(new CustomerCreateOptions
{
Email = user.Email,
Metadata = new Dictionary<string, string>
{
["user_id"] = userId.ToString()
}
}, cancellationToken: ct);
customerId = customer.Id;
user.StripeCustomerId = customerId;
await db.SaveChangesAsync(ct);
}
var priceId = GetPriceIdForPlan(plan);
if (string.IsNullOrEmpty(priceId))
{
throw new InvalidOperationException($"No price configured for plan: {plan}");
}
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(new SessionCreateOptions
{
Customer = customerId,
Mode = "subscription",
PaymentMethodTypes = ["card"],
LineItems =
[
new SessionLineItemOptions
{
Price = priceId,
Quantity = 1
}
],
SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}",
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
["user_id"] = userId.ToString(),
["workspace_id"] = workspaceId.ToString(),
["plan"] = plan.ToString()
},
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new Dictionary<string, string>
{
["user_id"] = userId.ToString(),
["workspace_id"] = workspaceId.ToString()
}
}
}, cancellationToken: ct);
return session.Url;
}
public async Task<string> CreateCustomerPortalSessionAsync(
Guid userId,
string returnUrl,
CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var user = await db.Users.FindAsync([userId], ct)
?? throw new InvalidOperationException("User not found");
if (string.IsNullOrEmpty(user.StripeCustomerId))
{
throw new InvalidOperationException("User has no Stripe customer");
}
var sessionService = new Stripe.BillingPortal.SessionService();
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
{
Customer = user.StripeCustomerId,
ReturnUrl = returnUrl
}, cancellationToken: ct);
return session.Url;
}
public async Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
{
var service = new SubscriptionService();
try
{
return await service.GetAsync(subscriptionId, cancellationToken: ct);
}
catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing")
{
return null;
}
}
public async Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
{
var service = new SubscriptionService();
await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions
{
InvoiceNow = false,
Prorate = false
}, cancellationToken: ct);
}
public async Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default)
{
var workspaceIdStr = session.Metadata.GetValueOrDefault("workspace_id");
var planStr = session.Metadata.GetValueOrDefault("plan");
if (string.IsNullOrEmpty(workspaceIdStr) || string.IsNullOrEmpty(planStr))
{
_logger.LogWarning("Checkout session missing metadata: {SessionId}", session.Id);
return;
}
if (!Guid.TryParse(workspaceIdStr, out var workspaceId))
{
_logger.LogWarning("Invalid workspace_id in checkout session: {WorkspaceId}", workspaceIdStr);
return;
}
if (!Enum.TryParse<WorkspacePlan>(planStr, out var plan))
{
_logger.LogWarning("Invalid plan in checkout session: {Plan}", planStr);
return;
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces.FindAsync([workspaceId], ct);
if (workspace == null)
{
_logger.LogWarning("Workspace not found for checkout: {WorkspaceId}", workspaceId);
return;
}
workspace.Plan = plan;
workspace.StripeSubscriptionId = session.SubscriptionId;
// Get subscription to set end date
if (!string.IsNullOrEmpty(session.SubscriptionId))
{
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
if (subscription != null)
{
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
}
}
await db.SaveChangesAsync(ct);
_logger.LogInformation(
"Workspace {WorkspaceId} upgraded to {Plan} via checkout {SessionId}",
workspaceId, plan, session.Id);
}
public async Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
if (workspace == null)
{
_logger.LogWarning("No workspace found for subscription: {SubscriptionId}", subscription.Id);
return;
}
// Update plan based on price
var priceId = subscription.Items.Data.FirstOrDefault()?.Price?.Id;
if (!string.IsNullOrEmpty(priceId))
{
var newPlan = GetPlanForPriceId(priceId);
if (workspace.Plan != newPlan)
{
_logger.LogInformation(
"Workspace {WorkspaceId} plan changed from {OldPlan} to {NewPlan}",
workspace.Id, workspace.Plan, newPlan);
workspace.Plan = newPlan;
}
}
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
// Handle cancellation at period end
if (subscription.CancelAtPeriodEnd)
{
_logger.LogInformation(
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
workspace.Id, subscription.CurrentPeriodEnd);
}
await db.SaveChangesAsync(ct);
}
public async Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
if (workspace == null)
{
_logger.LogWarning("No workspace found for deleted subscription: {SubscriptionId}", subscription.Id);
return;
}
_logger.LogInformation(
"Workspace {WorkspaceId} subscription deleted, downgrading to Free",
workspace.Id);
workspace.Plan = WorkspacePlan.Free;
workspace.StripeSubscriptionId = null;
workspace.SubscriptionEndsAt = null;
await db.SaveChangesAsync(ct);
}
public string GetPriceIdForPlan(WorkspacePlan plan)
{
return plan switch
{
WorkspacePlan.Pro => _settings.ProPriceId,
WorkspacePlan.Business => _settings.BusinessPriceId,
_ => string.Empty
};
}
public WorkspacePlan GetPlanForPriceId(string priceId)
{
if (priceId == _settings.ProPriceId)
return WorkspacePlan.Pro;
if (priceId == _settings.BusinessPriceId)
return WorkspacePlan.Business;
return WorkspacePlan.Free;
}
}

View File

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

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using api.Data; using api.Data;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using api.Features.Domains.Common; using api.Features.Domains.Common;
using api.Features.Plans.Services;
using api.Models; using api.Models;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
@@ -28,7 +29,7 @@ public class AddDomainValidator : Validator<AddDomainRequest>
} }
} }
public class AddDomainEndpoint(AppDbContext db) public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<AddDomainRequest, DomainResponse> : Endpoint<AddDomainRequest, DomainResponse>
{ {
public override void Configure() public override void Configure()
@@ -50,6 +51,16 @@ public class AddDomainEndpoint(AppDbContext db)
return; return;
} }
// Check plan limits
if (!await planLimits.CanCreateDomainAsync(req.WorkspaceId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Domain limit reached. Please upgrade your plan to add more custom domains."),
402,
cancellation: ct);
return;
}
// Normalize hostname (lowercase, no trailing dots) // Normalize hostname (lowercase, no trailing dots)
var hostname = req.Hostname.ToLowerInvariant().TrimEnd('.'); var hostname = req.Hostname.ToLowerInvariant().TrimEnd('.');

View File

@@ -0,0 +1,60 @@
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
namespace api.Features.Email.Services;
/// <summary>
/// Development email service that logs emails to console instead of sending them.
/// Useful for testing without a real SMTP server.
/// </summary>
public class ConsoleEmailService : IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<ConsoleEmailService> _logger;
public ConsoleEmailService(IOptions<EmailSettings> settings, ILogger<ConsoleEmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
{
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
var (subject, _, textBody) = EmailTemplates.PasswordReset(resetUrl);
LogEmail(toEmail, subject, textBody, resetUrl);
return Task.CompletedTask;
}
public Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
{
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
var (subject, _, textBody) = EmailTemplates.EmailVerification(verifyUrl);
LogEmail(toEmail, subject, textBody, verifyUrl);
return Task.CompletedTask;
}
public Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
{
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
var (subject, _, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
LogEmail(toEmail, subject, textBody, dashboardUrl);
return Task.CompletedTask;
}
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
{
_logger.LogInformation($"""
╔══════════════════════════════════════════════════════════════╗
║ EMAIL (Console Mode) ║
╚══════════════════════════════════════════════════════════════╝
To: {toEmail}
Subject: {subject}
Action URL: {actionUrl}
""");
}
}

View File

@@ -0,0 +1,36 @@
namespace api.Features.Email.Services;
public interface IEmailService
{
Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default);
Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default);
Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default);
}
public class EmailSettings
{
public string Provider { get; set; } = "smtp"; // smtp, sendgrid, ses
public string FromEmail { get; set; } = "noreply@trakqr.com";
public string FromName { get; set; } = "TrakQR";
public string BaseUrl { get; set; } = "https://trakqr.com";
// SMTP settings
public SmtpSettings? Smtp { get; set; }
// SendGrid settings
public SendGridSettings? SendGrid { get; set; }
}
public class SmtpSettings
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true;
public string? Username { get; set; }
public string? Password { get; set; }
}
public class SendGridSettings
{
public string ApiKey { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Mail;
using api.Features.Email.Templates;
using Microsoft.Extensions.Options;
namespace api.Features.Email.Services;
public class SmtpEmailService : IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<SmtpEmailService> _logger;
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
public async Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
{
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
var (subject, htmlBody, textBody) = EmailTemplates.PasswordReset(resetUrl);
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
}
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
{
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
_logger.LogInformation("Verification email sent to {Email}", toEmail);
}
public async Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
{
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
var (subject, htmlBody, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
}
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody, CancellationToken ct)
{
if (_settings.Smtp == null)
{
_logger.LogWarning("SMTP settings not configured. Email not sent to {Email}", toEmail);
return;
}
try
{
using var message = new MailMessage
{
From = new MailAddress(_settings.FromEmail, _settings.FromName),
Subject = subject,
IsBodyHtml = true,
Body = htmlBody
};
message.To.Add(new MailAddress(toEmail));
// Add plain text alternative
var plainTextView = AlternateView.CreateAlternateViewFromString(textBody, null, "text/plain");
var htmlView = AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html");
message.AlternateViews.Add(plainTextView);
message.AlternateViews.Add(htmlView);
using var client = new SmtpClient(_settings.Smtp.Host, _settings.Smtp.Port)
{
EnableSsl = _settings.Smtp.UseSsl,
DeliveryMethod = SmtpDeliveryMethod.Network
};
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
{
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
}
await client.SendMailAsync(message, ct);
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {Email}", toEmail);
throw;
}
}
}

View File

@@ -0,0 +1,221 @@
namespace api.Features.Email.Templates;
public static class EmailTemplates
{
public static (string Subject, string HtmlBody, string TextBody) PasswordReset(string resetUrl)
{
var subject = "Reset your TrakQR password";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Reset Your Password</title>
</head>
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
<tr>
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 20px;"">
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Reset your password</h1>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
<p style=""margin: 0;"">We received a request to reset your password. Click the button below to choose a new password.</p>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<a href=""{resetUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Reset Password</a>
</td>
</tr>
<tr>
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
<p style=""margin: 0;"">This link will expire in 1 hour.</p>
<p style=""margin: 10px 0 0 0;"">If you didn't request this, you can safely ignore this email.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
<p style=""margin: 0;"">&copy; TrakQR. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>";
var textBody = $@"Reset Your Password
We received a request to reset your TrakQR password.
Click the link below to reset your password:
{resetUrl}
This link will expire in 1 hour.
If you didn't request this, you can safely ignore this email.
- The TrakQR Team";
return (subject, htmlBody, textBody);
}
public static (string Subject, string HtmlBody, string TextBody) EmailVerification(string verifyUrl)
{
var subject = "Verify your TrakQR email";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Verify Your Email</title>
</head>
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
<tr>
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 20px;"">
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Verify your email</h1>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
<p style=""margin: 0;"">Thanks for signing up! Please verify your email address to get started with TrakQR.</p>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<a href=""{verifyUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Verify Email</a>
</td>
</tr>
<tr>
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
<p style=""margin: 0;"">This link will expire in 24 hours.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
<p style=""margin: 0;"">&copy; TrakQR. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>";
var textBody = $@"Verify Your Email
Thanks for signing up for TrakQR!
Please verify your email address by clicking the link below:
{verifyUrl}
This link will expire in 24 hours.
- The TrakQR Team";
return (subject, htmlBody, textBody);
}
public static (string Subject, string HtmlBody, string TextBody) Welcome(string userName, string dashboardUrl)
{
var subject = "Welcome to TrakQR!";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Welcome to TrakQR</title>
</head>
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
<tr>
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 20px;"">
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Welcome to TrakQR!</h1>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
<p style=""margin: 0;"">Hi {userName},</p>
<p style=""margin: 10px 0 0 0;"">You're all set! Start creating short links and beautiful QR codes with powerful analytics.</p>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-bottom: 30px;"">
<a href=""{dashboardUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Go to Dashboard</a>
</td>
</tr>
<tr>
<td style=""padding: 20px; background: #f9f9f9; border-radius: 12px;"">
<h3 style=""margin: 0 0 15px 0; font-size: 16px; color: #1a1a1a;"">Get started:</h3>
<ul style=""margin: 0; padding-left: 20px; color: #666666; line-height: 1.8;"">
<li>Create your first short link</li>
<li>Design a custom QR code</li>
<li>Track clicks and scans in real-time</li>
</ul>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
<p style=""margin: 0;"">&copy; TrakQR. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>";
var textBody = $@"Welcome to TrakQR!
Hi {userName},
You're all set! Start creating short links and beautiful QR codes with powerful analytics.
Get started:
- Create your first short link
- Design a custom QR code
- Track clicks and scans in real-time
Go to your dashboard: {dashboardUrl}
- The TrakQR Team";
return (subject, htmlBody, textBody);
}
}

View File

@@ -13,7 +13,7 @@ public interface IEventTrackingService
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context); Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
} }
public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<EventTrackingService> logger) public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpService geoIpService, ILogger<EventTrackingService> logger)
: IEventTrackingService : IEventTrackingService
{ {
// Dedupe window - same visitor clicking same link within this window counts as one // Dedupe window - same visitor clicking same link within this window counts as one
@@ -72,6 +72,7 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<Eve
var ipHash = HashIpAddress(ipAddress); var ipHash = HashIpAddress(ipAddress);
var deviceType = ParseDeviceType(userAgent); var deviceType = ParseDeviceType(userAgent);
var countryCode = geoIpService.GetCountryCode(ipAddress);
var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId); var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId);
// Check for duplicate within the dedupe window // Check for duplicate within the dedupe window
@@ -95,7 +96,7 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<Eve
IpHash = ipHash, IpHash = ipHash,
UserAgent = TruncateString(userAgent, 512), UserAgent = TruncateString(userAgent, 512),
Referrer = TruncateString(referrer, 2048), Referrer = TruncateString(referrer, 2048),
CountryCode = null, // TODO: GeoIP lookup CountryCode = countryCode,
DeviceType = deviceType, DeviceType = deviceType,
DedupeKey = dedupeKey DedupeKey = dedupeKey
}; };

View File

@@ -0,0 +1,95 @@
using System.Net;
using MaxMind.GeoIP2;
namespace api.Features.Events.Services;
public interface IGeoIpService
{
string? GetCountryCode(string ipAddress);
}
public class GeoIpService : IGeoIpService, IDisposable
{
private readonly DatabaseReader? _reader;
private readonly ILogger<GeoIpService> _logger;
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
{
_logger = logger;
var dbPath = configuration["GeoIP:DatabasePath"];
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
{
try
{
_reader = new DatabaseReader(dbPath);
_logger.LogInformation("GeoIP database loaded from {Path}", dbPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
}
}
else
{
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
}
}
public string? GetCountryCode(string ipAddress)
{
if (_reader == null)
return null;
try
{
// Handle localhost and private IPs
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress))
{
return null;
}
if (!IPAddress.TryParse(ipAddress, out var ip))
{
return null;
}
if (_reader.TryCountry(ip, out var response))
{
return response?.Country?.IsoCode;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to lookup country for IP {IP}", ipAddress);
}
return null;
}
private static bool IsPrivateIp(string ipAddress)
{
if (!IPAddress.TryParse(ipAddress, out var ip))
return false;
var bytes = ip.GetAddressBytes();
// Check for IPv4 private ranges
if (bytes.Length == 4)
{
// 10.0.0.0/8
if (bytes[0] == 10) return true;
// 172.16.0.0/12
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
// 192.168.0.0/16
if (bytes[0] == 192 && bytes[1] == 168) return true;
}
return false;
}
public void Dispose()
{
_reader?.Dispose();
}
}

View File

@@ -0,0 +1,12 @@
namespace api.Features.Links.Endpoints;
public class LinkDto
{
public required Guid Id { get; set; }
public required string Slug { get; set; }
public required string DestinationUrl { get; set; }
public required string? Title { get; set; }
public required string Status { get; set; }
public int ClickCount { get; set; }
public DateTimeOffset CreatedAt { get; set; }
};

View File

@@ -12,7 +12,8 @@ public record LinkResponse(
DateTime? ExpiresAt, DateTime? ExpiresAt,
bool HasPassword, bool HasPassword,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt DateTime UpdatedAt,
DateTime? DeletedAt = null
); );
public record LinkListResponse( public record LinkListResponse(

View File

@@ -0,0 +1,177 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Links.Endpoints;
public class BulkCreateLinksRequest
{
public Guid WorkspaceId { get; set; }
public required List<BulkLinkItem> Links { get; set; }
}
public class BulkLinkItem
{
public required string DestinationUrl { get; set; }
public string? Title { get; set; }
public string? Slug { get; set; }
}
public class BulkCreateLinksResponse
{
public required List<LinkDto> Created { get; set; }
public required List<BulkLinkError> Errors { get; set; }
}
public class BulkLinkError
{
public int Index { get; set; }
public required string Url { get; set; }
public required string Error { get; set; }
}
public class BulkCreateLinksEndpoint(AppDbContext db)
: Endpoint<BulkCreateLinksRequest, BulkCreateLinksResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/links/bulk");
}
public override async Task HandleAsync(BulkCreateLinksRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspace = await db.Workspaces
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (workspace is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
// Limit bulk creation to 100 links at a time
if (req.Links.Count > 100)
{
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400, cancellation: ct);
return;
}
var created = new List<LinkDto>();
var errors = new List<BulkLinkError>();
// Check for plan limits
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
var linkLimit = GetPlanLinkLimit(workspace.Plan);
for (int i = 0; i < req.Links.Count; i++)
{
var item = req.Links[i];
// Validate URL
if (!Uri.TryCreate(item.DestinationUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
errors.Add(new BulkLinkError
{
Index = i,
Url = item.DestinationUrl,
Error = "Invalid URL"
});
continue;
}
// Check plan limits
if (linkLimit.HasValue && currentLinkCount + created.Count >= linkLimit.Value)
{
errors.Add(new BulkLinkError
{
Index = i,
Url = item.DestinationUrl,
Error = "Plan link limit reached"
});
continue;
}
// Generate or validate slug
var slug = item.Slug;
if (string.IsNullOrWhiteSpace(slug))
{
slug = GenerateSlug();
}
else
{
// Check if slug is taken
var slugTaken = await db.ShortLinks.AnyAsync(l => l.Slug == slug, ct);
if (slugTaken)
{
errors.Add(new BulkLinkError
{
Index = i,
Url = item.DestinationUrl,
Error = $"Slug '{slug}' is already taken"
});
continue;
}
}
var link = new ShortLink
{
Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId,
Slug = slug,
DestinationUrl = item.DestinationUrl,
Title = item.Title,
Status = ShortLinkStatus.Active,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.ShortLinks.Add(link);
created.Add(new LinkDto
{
Id = link.Id,
Slug = link.Slug,
DestinationUrl = link.DestinationUrl,
Title = link?.Title,
Status = link.Status.ToString(),
ClickCount = 0,
CreatedAt = link.CreatedAt,
});
}
await db.SaveChangesAsync(ct);
var response = new BulkCreateLinksResponse
{
Created = created,
Errors = errors
};
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
private static string GenerateSlug()
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var random = new Random();
return new string(Enumerable.Repeat(chars, 7).Select(s => s[random.Next(s.Length)]).ToArray());
}
private static int? GetPlanLinkLimit(WorkspacePlan? plan)
{
return plan switch
{
WorkspacePlan.Business => null, // Unlimited
WorkspacePlan.Pro => 10000,
_ => 100 // Free plan
};
}
}

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using api.Data; using api.Data;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using api.Features.Links.Common; using api.Features.Links.Common;
using api.Features.Plans.Services;
using api.Models; using api.Models;
using FastEndpoints; using FastEndpoints;
using FluentValidation; using FluentValidation;
@@ -43,7 +44,7 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
} }
} }
public class CreateLinkEndpoint(AppDbContext db) public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<CreateLinkRequest, LinkResponse> : Endpoint<CreateLinkRequest, LinkResponse>
{ {
public override void Configure() public override void Configure()
@@ -65,6 +66,16 @@ public class CreateLinkEndpoint(AppDbContext db)
return; return;
} }
// Check plan limits
if (!await planLimits.CanCreateLinkAsync(req.WorkspaceId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Link limit reached. Please upgrade your plan to create more links."),
402,
cancellation: ct);
return;
}
// Verify project belongs to workspace if specified // Verify project belongs to workspace if specified
if (req.ProjectId.HasValue) if (req.ProjectId.HasValue)
{ {

View File

@@ -26,7 +26,11 @@ public class DeleteLinkEndpoint(AppDbContext db)
var link = await db.ShortLinks var link = await db.ShortLinks
.Include(l => l.Workspace) .Include(l => l.Workspace)
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct); .FirstOrDefaultAsync(l =>
l.Id == req.Id &&
l.WorkspaceId == req.WorkspaceId &&
l.Workspace.OwnerUserId == userId &&
l.DeletedAt == null, ct);
if (link is null) if (link is null)
{ {
@@ -34,7 +38,8 @@ public class DeleteLinkEndpoint(AppDbContext db)
return; return;
} }
db.ShortLinks.Remove(link); // Soft delete
link.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct); await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct);

View File

@@ -26,7 +26,7 @@ public class GetLinkEndpoint(AppDbContext db)
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks var link = await db.ShortLinks
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId) .Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId && l.DeletedAt == null)
.Select(l => new LinkResponse( .Select(l => new LinkResponse(
l.Id, l.Id,
l.WorkspaceId, l.WorkspaceId,
@@ -39,7 +39,8 @@ public class GetLinkEndpoint(AppDbContext db)
l.ExpiresAt, l.ExpiresAt,
l.PasswordHash != null, l.PasswordHash != null,
l.CreatedAt, l.CreatedAt,
l.UpdatedAt l.UpdatedAt,
l.DeletedAt
)) ))
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);

View File

@@ -12,6 +12,7 @@ public class ListLinksRequest
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; } public Guid? ProjectId { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public bool IncludeDeleted { get; set; } = false;
} }
public class ListLinksEndpoint(AppDbContext db) public class ListLinksEndpoint(AppDbContext db)
@@ -39,6 +40,12 @@ public class ListLinksEndpoint(AppDbContext db)
var query = db.ShortLinks var query = db.ShortLinks
.Where(l => l.WorkspaceId == req.WorkspaceId); .Where(l => l.WorkspaceId == req.WorkspaceId);
// Filter by deleted status (exclude soft-deleted by default)
if (!req.IncludeDeleted)
{
query = query.Where(l => l.DeletedAt == null);
}
// Filter by project if specified // Filter by project if specified
if (req.ProjectId.HasValue) if (req.ProjectId.HasValue)
{ {
@@ -65,7 +72,8 @@ public class ListLinksEndpoint(AppDbContext db)
l.ExpiresAt, l.ExpiresAt,
l.PasswordHash != null, l.PasswordHash != null,
l.CreatedAt, l.CreatedAt,
l.UpdatedAt l.UpdatedAt,
l.DeletedAt
)) ))
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -0,0 +1,65 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Links.Common;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Links.Endpoints;
public class RestoreLinkRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
}
public class RestoreLinkEndpoint(AppDbContext db)
: Endpoint<RestoreLinkRequest, LinkResponse>
{
public override void Configure()
{
Post("/workspaces/{WorkspaceId}/links/{Id}/restore");
}
public override async Task HandleAsync(RestoreLinkRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var link = await db.ShortLinks
.Include(l => l.Workspace)
.FirstOrDefaultAsync(l =>
l.Id == req.Id &&
l.WorkspaceId == req.WorkspaceId &&
l.Workspace.OwnerUserId == userId &&
l.DeletedAt != null, ct);
if (link is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Deleted link not found"), 404, cancellation: ct);
return;
}
// Restore the link
link.DeletedAt = null;
link.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var response = new LinkResponse(
link.Id,
link.WorkspaceId,
link.ProjectId,
link.DomainId,
link.Slug,
link.DestinationUrl,
link.Title,
link.Status.ToString(),
link.ExpiresAt,
link.PasswordHash != null,
link.CreatedAt,
link.UpdatedAt,
link.DeletedAt
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,94 @@
using System.Security.Claims;
using api.Features.Plans.Services;
using FastEndpoints;
namespace api.Features.Plans.Endpoints;
public class GetUsageRequest
{
public Guid? WorkspaceId { get; set; }
}
public record UsageResponse(
int Workspaces,
int Links,
int QRCodes,
int Domains,
int EventsThisMonth,
string Plan,
LimitsResponse Limits
);
public record LimitsResponse(
int MaxWorkspaces,
int MaxLinks,
int MaxQRCodes,
int MaxDomains,
int MaxEventsPerMonth,
bool HasCustomDomains,
bool HasPasswordProtection
);
public class GetUsageEndpoint(IPlanLimitsService planLimits)
: Endpoint<GetUsageRequest, UsageResponse>
{
public override void Configure()
{
Get("/usage");
}
public override async Task HandleAsync(GetUsageRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
if (req.WorkspaceId.HasValue)
{
var wsUsage = await planLimits.GetWorkspaceUsageAsync(req.WorkspaceId.Value, ct);
var response = new UsageResponse(
Workspaces: 1,
Links: wsUsage.Links,
QRCodes: wsUsage.QRCodes,
Domains: wsUsage.Domains,
EventsThisMonth: wsUsage.EventsThisMonth,
Plan: wsUsage.Plan.ToString(),
Limits: new LimitsResponse(
MaxWorkspaces: wsUsage.Limits.MaxWorkspaces,
MaxLinks: wsUsage.Limits.MaxLinksPerWorkspace,
MaxQRCodes: wsUsage.Limits.MaxQRCodesPerWorkspace,
MaxDomains: wsUsage.Limits.MaxDomainsPerWorkspace,
MaxEventsPerMonth: wsUsage.Limits.MaxEventsPerMonth,
HasCustomDomains: wsUsage.Limits.HasCustomDomains,
HasPasswordProtection: wsUsage.Limits.HasPasswordProtection
)
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
else
{
var usage = await planLimits.GetUsageAsync(userId, ct);
var limits = planLimits.GetLimits(usage.HighestPlan);
var response = new UsageResponse(
Workspaces: usage.TotalWorkspaces,
Links: usage.TotalLinks,
QRCodes: usage.TotalQRCodes,
Domains: usage.TotalDomains,
EventsThisMonth: usage.EventsThisMonth,
Plan: usage.HighestPlan.ToString(),
Limits: new LimitsResponse(
MaxWorkspaces: limits.MaxWorkspaces,
MaxLinks: limits.MaxLinksPerWorkspace,
MaxQRCodes: limits.MaxQRCodesPerWorkspace,
MaxDomains: limits.MaxDomainsPerWorkspace,
MaxEventsPerMonth: limits.MaxEventsPerMonth,
HasCustomDomains: limits.HasCustomDomains,
HasPasswordProtection: limits.HasPasswordProtection
)
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
}
}

View File

@@ -0,0 +1,190 @@
using api.Data;
using api.Models;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Plans.Services;
public interface IPlanLimitsService
{
PlanLimits GetLimits(WorkspacePlan plan);
Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default);
Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default);
Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default);
Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default);
}
public record PlanLimits(
int MaxWorkspaces,
int MaxLinksPerWorkspace,
int MaxQRCodesPerWorkspace,
int MaxDomainsPerWorkspace,
int MaxEventsPerMonth,
bool HasCustomDomains,
bool HasPasswordProtection,
bool HasAnalytics
);
public record UsageStats(
int TotalWorkspaces,
int TotalLinks,
int TotalQRCodes,
int TotalDomains,
int EventsThisMonth,
WorkspacePlan HighestPlan
);
public record WorkspaceUsageStats(
Guid WorkspaceId,
WorkspacePlan Plan,
int Links,
int QRCodes,
int Domains,
int EventsThisMonth,
PlanLimits Limits
);
public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsService
{
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
{
[WorkspacePlan.Free] = new PlanLimits(
MaxWorkspaces: 1,
MaxLinksPerWorkspace: 50,
MaxQRCodesPerWorkspace: 25,
MaxDomainsPerWorkspace: 0,
MaxEventsPerMonth: 10_000,
HasCustomDomains: false,
HasPasswordProtection: false,
HasAnalytics: true
),
[WorkspacePlan.Pro] = new PlanLimits(
MaxWorkspaces: 5,
MaxLinksPerWorkspace: 5_000,
MaxQRCodesPerWorkspace: 1_000,
MaxDomainsPerWorkspace: 3,
MaxEventsPerMonth: 100_000,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
),
[WorkspacePlan.Business] = new PlanLimits(
MaxWorkspaces: int.MaxValue,
MaxLinksPerWorkspace: int.MaxValue,
MaxQRCodesPerWorkspace: int.MaxValue,
MaxDomainsPerWorkspace: int.MaxValue,
MaxEventsPerMonth: int.MaxValue,
HasCustomDomains: true,
HasPasswordProtection: true,
HasAnalytics: true
)
};
public PlanLimits GetLimits(WorkspacePlan plan) => PlanConfigs[plan];
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspaces = await db.Workspaces
.Where(w => w.OwnerUserId == userId)
.Select(w => new { w.Id, w.Plan })
.ToListAsync(ct);
var workspaceIds = workspaces.Select(w => w.Id).ToList();
var totalLinks = await db.ShortLinks
.CountAsync(l => workspaceIds.Contains(l.WorkspaceId), ct);
var totalQRCodes = await db.QrCodeDesigns
.CountAsync(q => workspaceIds.Contains(q.WorkspaceId), ct);
var totalDomains = await db.Domains
.CountAsync(d => workspaceIds.Contains(d.WorkspaceId), ct);
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var eventsThisMonth = await db.Events
.CountAsync(e => workspaceIds.Contains(e.WorkspaceId) && e.Timestamp >= monthStart, ct);
var highestPlan = workspaces.Any()
? workspaces.Max(w => w.Plan)
: WorkspacePlan.Free;
return new UsageStats(
TotalWorkspaces: workspaces.Count,
TotalLinks: totalLinks,
TotalQRCodes: totalQRCodes,
TotalDomains: totalDomains,
EventsThisMonth: eventsThisMonth,
HighestPlan: highestPlan
);
}
public async Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces
.Where(w => w.Id == workspaceId)
.Select(w => new { w.Id, w.Plan })
.FirstOrDefaultAsync(ct);
if (workspace == null)
throw new KeyNotFoundException("Workspace not found");
var links = await db.ShortLinks.CountAsync(l => l.WorkspaceId == workspaceId, ct);
var qrCodes = await db.QrCodeDesigns.CountAsync(q => q.WorkspaceId == workspaceId, ct);
var domains = await db.Domains.CountAsync(d => d.WorkspaceId == workspaceId, ct);
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var eventsThisMonth = await db.Events
.CountAsync(e => e.WorkspaceId == workspaceId && e.Timestamp >= monthStart, ct);
var limits = GetLimits(workspace.Plan);
return new WorkspaceUsageStats(
WorkspaceId: workspaceId,
Plan: workspace.Plan,
Links: links,
QRCodes: qrCodes,
Domains: domains,
EventsThisMonth: eventsThisMonth,
Limits: limits
);
}
public async Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default)
{
var usage = await GetUsageAsync(userId, ct);
var limits = GetLimits(usage.HighestPlan);
return usage.TotalWorkspaces < limits.MaxWorkspaces;
}
public async Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.Links < usage.Limits.MaxLinksPerWorkspace;
}
public async Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.QRCodes < usage.Limits.MaxQRCodesPerWorkspace;
}
public async Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.Domains < usage.Limits.MaxDomainsPerWorkspace;
}
public async Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default)
{
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth;
}
}

View File

@@ -4,6 +4,9 @@ public record ProjectResponse(
Guid Id, Guid Id,
Guid WorkspaceId, Guid WorkspaceId,
string Name, string Name,
string? Description,
int LinkCount,
int QRCodeCount,
DateTime CreatedAt DateTime CreatedAt
); );

View File

@@ -13,6 +13,7 @@ public class CreateProjectRequest
{ {
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
} }
public class CreateProjectValidator : Validator<CreateProjectRequest> public class CreateProjectValidator : Validator<CreateProjectRequest>
@@ -52,6 +53,7 @@ public class CreateProjectEndpoint(AppDbContext db)
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
WorkspaceId = req.WorkspaceId, WorkspaceId = req.WorkspaceId,
Name = req.Name, Name = req.Name,
Description = req.Description,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -62,6 +64,9 @@ public class CreateProjectEndpoint(AppDbContext db)
project.Id, project.Id,
project.WorkspaceId, project.WorkspaceId,
project.Name, project.Name,
project.Description,
0, // LinkCount - new project has no links
0, // QRCodeCount - new project has no QR codes
project.CreatedAt project.CreatedAt
); );

View File

@@ -31,6 +31,9 @@ public class GetProjectEndpoint(AppDbContext db)
p.Id, p.Id,
p.WorkspaceId, p.WorkspaceId,
p.Name, p.Name,
p.Description,
p.ShortLinks.Count(l => l.DeletedAt == null),
p.QRCodeDesigns.Count,
p.CreatedAt p.CreatedAt
)) ))
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);

View File

@@ -41,6 +41,9 @@ public class ListProjectsEndpoint(AppDbContext db)
p.Id, p.Id,
p.WorkspaceId, p.WorkspaceId,
p.Name, p.Name,
p.Description,
p.ShortLinks.Count(l => l.DeletedAt == null),
p.QRCodeDesigns.Count,
p.CreatedAt p.CreatedAt
)) ))
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -13,6 +13,7 @@ public class UpdateProjectRequest
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid Id { get; set; } public Guid Id { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
} }
public class UpdateProjectValidator : Validator<UpdateProjectRequest> public class UpdateProjectValidator : Validator<UpdateProjectRequest>
@@ -39,6 +40,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
var project = await db.Projects var project = await db.Projects
.Include(p => p.Workspace) .Include(p => p.Workspace)
.Include(p => p.ShortLinks)
.Include(p => p.QRCodeDesigns)
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct); .FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
if (project is null) if (project is null)
@@ -48,12 +51,16 @@ public class UpdateProjectEndpoint(AppDbContext db)
} }
project.Name = req.Name; project.Name = req.Name;
project.Description = req.Description;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var response = new ProjectResponse( var response = new ProjectResponse(
project.Id, project.Id,
project.WorkspaceId, project.WorkspaceId,
project.Name, project.Name,
project.Description,
project.ShortLinks.Count(l => l.DeletedAt == null),
project.QRCodeDesigns.Count,
project.CreatedAt project.CreatedAt
); );

View File

@@ -35,8 +35,10 @@ public record QRCodeResponse(
Guid? ProjectId, Guid? ProjectId,
Guid? ShortLinkId, Guid? ShortLinkId,
string? ShortLinkSlug, string? ShortLinkSlug,
string Name,
QRCodeStyle Style, QRCodeStyle Style,
Guid? LogoAssetId, Guid? LogoAssetId,
string? LogoUrl,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt DateTime UpdatedAt
); );

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using api.Data; using api.Data;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using api.Features.Plans.Services;
using api.Features.QRCodes.Common; using api.Features.QRCodes.Common;
using api.Models; using api.Models;
using FastEndpoints; using FastEndpoints;
@@ -15,6 +16,8 @@ public class CreateQRCodeRequest
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; } public Guid? ProjectId { get; set; }
public Guid? ShortLinkId { get; set; } public Guid? ShortLinkId { get; set; }
public Guid? LogoAssetId { get; set; }
public string? Name { get; set; }
public QRCodeStyle? Style { get; set; } public QRCodeStyle? Style { get; set; }
} }
@@ -27,7 +30,7 @@ public class CreateQRCodeValidator : Validator<CreateQRCodeRequest>
} }
} }
public class CreateQRCodeEndpoint(AppDbContext db) public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<CreateQRCodeRequest, QRCodeResponse> : Endpoint<CreateQRCodeRequest, QRCodeResponse>
{ {
public override void Configure() public override void Configure()
@@ -49,6 +52,16 @@ public class CreateQRCodeEndpoint(AppDbContext db)
return; return;
} }
// Check plan limits
if (!await planLimits.CanCreateQRCodeAsync(req.WorkspaceId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("QR code limit reached. Please upgrade your plan to create more QR codes."),
402,
cancellation: ct);
return;
}
// Verify short link belongs to workspace // Verify short link belongs to workspace
string? linkSlug = null; string? linkSlug = null;
if (req.ShortLinkId.HasValue) if (req.ShortLinkId.HasValue)
@@ -79,7 +92,26 @@ public class CreateQRCodeEndpoint(AppDbContext db)
} }
} }
// Verify logo asset belongs to workspace if specified
string? logoUrl = null;
if (req.LogoAssetId.HasValue)
{
var asset = await db.Assets
.Where(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId)
.Select(a => new { a.StorageKey })
.FirstOrDefaultAsync(ct);
if (asset is null)
{
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
return;
}
logoUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/assets/{asset.StorageKey}";
}
var style = req.Style ?? new QRCodeStyle(); var style = req.Style ?? new QRCodeStyle();
var name = req.Name ?? $"QR Code {DateTime.UtcNow:yyyy-MM-dd}";
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var qrCode = new QRCodeDesign var qrCode = new QRCodeDesign
@@ -88,8 +120,9 @@ public class CreateQRCodeEndpoint(AppDbContext db)
WorkspaceId = req.WorkspaceId, WorkspaceId = req.WorkspaceId,
ProjectId = req.ProjectId, ProjectId = req.ProjectId,
ShortLinkId = req.ShortLinkId, ShortLinkId = req.ShortLinkId,
Name = name,
StyleJson = JsonSerializer.Serialize(style), StyleJson = JsonSerializer.Serialize(style),
LogoAssetId = null, LogoAssetId = req.LogoAssetId,
CreatedAt = now, CreatedAt = now,
UpdatedAt = now UpdatedAt = now
}; };
@@ -103,8 +136,10 @@ public class CreateQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId, qrCode.ProjectId,
qrCode.ShortLinkId, qrCode.ShortLinkId,
linkSlug, linkSlug,
qrCode.Name,
style, style,
qrCode.LogoAssetId, qrCode.LogoAssetId,
logoUrl,
qrCode.CreatedAt, qrCode.CreatedAt,
qrCode.UpdatedAt qrCode.UpdatedAt
); );

View File

@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using api.Data; using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using api.Features.QRCodes.Common; using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services; using api.Features.QRCodes.Services;
@@ -17,7 +18,7 @@ public class ExportQRCodeRequest
public int? Size { get; set; } public int? Size { get; set; }
} }
public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator) public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
: Endpoint<ExportQRCodeRequest> : Endpoint<ExportQRCodeRequest>
{ {
public override void Configure() public override void Configure()
@@ -31,6 +32,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen
var qrCode = await db.QrCodeDesigns var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink) .Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId) .Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
@@ -50,14 +52,28 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen
var format = (req.Format ?? "png").ToLowerInvariant(); var format = (req.Format ?? "png").ToLowerInvariant();
var size = req.Size ?? 512; var size = req.Size ?? 512;
// Build the short link URL // Build the short link URL with QR tracking param
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}"; var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}?qr={qrCode.Id}";
var filename = $"qrcode-{qrCode.ShortLink.Slug}"; var filename = $"qrcode-{qrCode.ShortLink.Slug}";
// Load logo if available
Stream? logoStream = null;
if (qrCode.LogoAsset != null)
{
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
}
try
{
if (format == "svg") if (format == "svg")
{ {
// SVG doesn't support logo overlay currently
var svg = qrGenerator.GenerateSvg(linkUrl, style, size); var svg = qrGenerator.GenerateSvg(linkUrl, style, size);
HttpContext.Response.ContentType = "image/svg+xml"; HttpContext.Response.ContentType = "image/svg+xml";
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\""; HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\"";
@@ -65,10 +81,15 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen
} }
else else
{ {
var png = qrGenerator.GeneratePng(linkUrl, style, size); var png = qrGenerator.GeneratePng(linkUrl, style, size, logoStream);
HttpContext.Response.ContentType = "image/png"; HttpContext.Response.ContentType = "image/png";
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\""; HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\"";
await HttpContext.Response.Body.WriteAsync(png, ct); await HttpContext.Response.Body.WriteAsync(png, ct);
} }
} }
finally
{
logoStream?.Dispose();
}
}
} }

View File

@@ -0,0 +1,138 @@
using System.Security.Claims;
using api.Data;
using api.Features.Auth.Common;
using api.Models;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
namespace api.Features.QRCodes.Endpoints;
public class GetQRCodeAnalyticsRequest
{
public Guid WorkspaceId { get; set; }
public Guid Id { get; set; }
public string Period { get; set; } = "7d";
}
public record QRCodeAnalyticsSummary(
int TotalScans,
int UniqueVisitors
);
public record QRCodeTimeSeriesPoint(
string Date,
int Scans
);
public record QRCodeAnalyticsResponse(
Guid QRCodeId,
string Name,
string? LinkSlug,
QRCodeAnalyticsSummary Summary,
List<QRCodeTimeSeriesPoint> TimeSeries,
Dictionary<string, int> DeviceBreakdown,
Dictionary<string, int> ReferrerBreakdown,
Dictionary<string, int> CountryBreakdown
);
public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
: Endpoint<GetQRCodeAnalyticsRequest, QRCodeAnalyticsResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/analytics");
}
public override async Task HandleAsync(GetQRCodeAnalyticsRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct);
if (qrCode == null)
{
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
return;
}
var startDate = GetStartDate(req.Period);
var events = await db.Events
.Where(e => e.QRCodeId == req.Id && e.Type == EventType.Scan && e.Timestamp >= startDate)
.ToListAsync(ct);
var summary = new QRCodeAnalyticsSummary(
TotalScans: events.Count,
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count()
);
var timeSeries = events
.GroupBy(e => e.Timestamp.Date)
.OrderBy(g => g.Key)
.Select(g => new QRCodeTimeSeriesPoint(
Date: g.Key.ToString("yyyy-MM-dd"),
Scans: g.Count()
))
.ToList();
var deviceBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
.GroupBy(e => e.DeviceType!)
.ToDictionary(g => g.Key, g => g.Count());
var referrerBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.Referrer))
.GroupBy(e => GetReferrerDomain(e.Referrer!))
.OrderByDescending(g => g.Count())
.Take(10)
.ToDictionary(g => g.Key, g => g.Count());
var countryBreakdown = events
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
.GroupBy(e => e.CountryCode!)
.OrderByDescending(g => g.Count())
.Take(10)
.ToDictionary(g => g.Key, g => g.Count());
var response = new QRCodeAnalyticsResponse(
QRCodeId: qrCode.Id,
Name: qrCode.Name,
LinkSlug: qrCode.ShortLink?.Slug,
Summary: summary,
TimeSeries: timeSeries,
DeviceBreakdown: deviceBreakdown,
ReferrerBreakdown: referrerBreakdown,
CountryBreakdown: countryBreakdown
);
await HttpContext.Response.SendAsync(response, cancellation: ct);
}
private static DateTime GetStartDate(string period)
{
return period switch
{
"24h" => DateTime.UtcNow.AddHours(-24),
"7d" => DateTime.UtcNow.AddDays(-7),
"30d" => DateTime.UtcNow.AddDays(-30),
"all" => DateTime.MinValue,
_ => DateTime.UtcNow.AddDays(-7)
};
}
private static string GetReferrerDomain(string referrer)
{
try
{
var uri = new Uri(referrer);
return uri.Host;
}
catch
{
return referrer;
}
}
}

View File

@@ -28,6 +28,7 @@ public class GetQRCodeEndpoint(AppDbContext db)
var qrCode = await db.QrCodeDesigns var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink) .Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId) .Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
@@ -38,6 +39,7 @@ public class GetQRCodeEndpoint(AppDbContext db)
} }
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle(); var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var response = new QRCodeResponse( var response = new QRCodeResponse(
qrCode.Id, qrCode.Id,
@@ -45,8 +47,10 @@ public class GetQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId, qrCode.ProjectId,
qrCode.ShortLinkId, qrCode.ShortLinkId,
qrCode.ShortLink?.Slug, qrCode.ShortLink?.Slug,
qrCode.Name,
style, style,
qrCode.LogoAssetId, qrCode.LogoAssetId,
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
qrCode.CreatedAt, qrCode.CreatedAt,
qrCode.UpdatedAt qrCode.UpdatedAt
); );

View File

@@ -52,9 +52,12 @@ public class ListQRCodesEndpoint(AppDbContext db)
var qrCodes = await query var qrCodes = await query
.Include(q => q.ShortLink) .Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.OrderByDescending(q => q.CreatedAt) .OrderByDescending(q => q.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var response = new QRCodeListResponse( var response = new QRCodeListResponse(
qrCodes.Select(q => new QRCodeResponse( qrCodes.Select(q => new QRCodeResponse(
q.Id, q.Id,
@@ -62,8 +65,10 @@ public class ListQRCodesEndpoint(AppDbContext db)
q.ProjectId, q.ProjectId,
q.ShortLinkId, q.ShortLinkId,
q.ShortLink?.Slug, q.ShortLink?.Slug,
q.Name,
JsonSerializer.Deserialize<QRCodeStyle>(q.StyleJson) ?? new QRCodeStyle(), JsonSerializer.Deserialize<QRCodeStyle>(q.StyleJson) ?? new QRCodeStyle(),
q.LogoAssetId, q.LogoAssetId,
q.LogoAsset != null ? $"{baseUrl}/assets/{q.LogoAsset.StorageKey}" : null,
q.CreatedAt, q.CreatedAt,
q.UpdatedAt q.UpdatedAt
)) ))

View File

@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using api.Data; using api.Data;
using api.Features.Assets.Services;
using api.Features.Auth.Common; using api.Features.Auth.Common;
using api.Features.QRCodes.Common; using api.Features.QRCodes.Common;
using api.Features.QRCodes.Services; using api.Features.QRCodes.Services;
@@ -16,7 +17,7 @@ public class PreviewQRCodeRequest
public int? Size { get; set; } public int? Size { get; set; }
} }
public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator) public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse> : Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
{ {
public override void Configure() public override void Configure()
@@ -30,6 +31,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe
var qrCode = await db.QrCodeDesigns var qrCode = await db.QrCodeDesigns
.Include(q => q.ShortLink) .Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId) .Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
@@ -53,7 +55,20 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}"; var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size); // Load logo if available
Stream? logoStream = null;
if (qrCode.LogoAsset != null)
{
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
if (logoResult.HasValue)
{
logoStream = logoResult.Value.Stream;
}
}
try
{
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
var response = new QRCodePreviewResponse( var response = new QRCodePreviewResponse(
DataUrl: dataUrl, DataUrl: dataUrl,
@@ -64,4 +79,9 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe
await HttpContext.Response.SendAsync(response, 200, cancellation: ct); await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
} }
finally
{
logoStream?.Dispose();
}
}
} }

View File

@@ -12,8 +12,11 @@ public class UpdateQRCodeRequest
{ {
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid Id { get; set; } public Guid Id { get; set; }
public string? Name { get; set; }
public Guid? ProjectId { get; set; } public Guid? ProjectId { get; set; }
public bool? RemoveProject { get; set; } public bool? RemoveProject { get; set; }
public Guid? LogoAssetId { get; set; }
public bool? RemoveLogo { get; set; }
public QRCodeStyle? Style { get; set; } public QRCodeStyle? Style { get; set; }
} }
@@ -32,6 +35,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
var qrCode = await db.QrCodeDesigns var qrCode = await db.QrCodeDesigns
.Include(q => q.Workspace) .Include(q => q.Workspace)
.Include(q => q.ShortLink) .Include(q => q.ShortLink)
.Include(q => q.LogoAsset)
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct); .FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
if (qrCode is null) if (qrCode is null)
@@ -58,6 +62,33 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId = null; qrCode.ProjectId = null;
} }
// Update name if provided
if (!string.IsNullOrWhiteSpace(req.Name))
{
qrCode.Name = req.Name;
}
// Handle logo asset update
if (req.LogoAssetId.HasValue)
{
var assetExists = await db.Assets
.AnyAsync(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId, ct);
if (!assetExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
return;
}
qrCode.LogoAssetId = req.LogoAssetId.Value;
// Reload the asset for the response
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
}
else if (req.RemoveLogo == true)
{
qrCode.LogoAssetId = null;
qrCode.LogoAsset = null;
}
if (req.Style != null) if (req.Style != null)
{ {
qrCode.StyleJson = JsonSerializer.Serialize(req.Style); qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
@@ -67,6 +98,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle(); var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var response = new QRCodeResponse( var response = new QRCodeResponse(
qrCode.Id, qrCode.Id,
@@ -74,8 +106,10 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
qrCode.ProjectId, qrCode.ProjectId,
qrCode.ShortLinkId, qrCode.ShortLinkId,
qrCode.ShortLink?.Slug, qrCode.ShortLink?.Slug,
qrCode.Name,
style, style,
qrCode.LogoAssetId, qrCode.LogoAssetId,
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
qrCode.CreatedAt, qrCode.CreatedAt,
qrCode.UpdatedAt qrCode.UpdatedAt
); );

View File

@@ -1,34 +1,129 @@
using System.Drawing;
using api.Features.QRCodes.Common; using api.Features.QRCodes.Common;
using QRCoder; using QRCoder;
using SkiaSharp;
namespace api.Features.QRCodes.Services; namespace api.Features.QRCodes.Services;
public interface IQRCodeGeneratorService public interface IQrCodeGeneratorService
{ {
byte[] GeneratePng(string content, QRCodeStyle style, int size = 512); byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null);
string GenerateSvg(string content, QRCodeStyle style, int size = 512); string GenerateSvg(string content, QRCodeStyle style, int size = 512);
string GenerateDataUrl(string content, QRCodeStyle style, int size = 256); string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null);
} }
public class QRCodeGeneratorService : IQRCodeGeneratorService public class QrCodeGeneratorService : IQrCodeGeneratorService
{ {
public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512) public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null)
{ {
using var qrGenerator = new QRCodeGenerator(); using var qrGenerator = new QRCodeGenerator();
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel); var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel); using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
using var qrCode = new PngByteQRCode(qrCodeData); var moduleMatrix = qrCodeData.ModuleMatrix;
var moduleCount = moduleMatrix.Count;
var foreground = ParseColor(style.ForegroundColor); // Calculate pixels per module based on desired size (accounting for quiet zone)
var background = ParseColor(style.BackgroundColor); var totalModules = moduleCount + (style.QuietZone * 2);
var pixelsPerModule = Math.Max(4, size / totalModules);
var actualSize = totalModules * pixelsPerModule;
// Calculate pixels per module based on desired size // Create bitmap with SkiaSharp for custom shapes
var moduleCount = qrCodeData.ModuleMatrix.Count; var foregroundColor = ParseSkColor(style.ForegroundColor);
var pixelsPerModule = Math.Max(1, size / moduleCount); var backgroundColor = ParseSkColor(style.BackgroundColor);
return qrCode.GetGraphic(pixelsPerModule, foreground, background, drawQuietZones: style.QuietZone > 0); using var surface = SKSurface.Create(new SKImageInfo(actualSize, actualSize));
var canvas = surface.Canvas;
// Draw background
canvas.Clear(backgroundColor);
// Draw QR modules with custom shapes
var modulePaint = new SKPaint
{
Color = foregroundColor,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
var quietZoneOffset = style.QuietZone * pixelsPerModule;
for (int y = 0; y < moduleCount; y++)
{
for (int x = 0; x < moduleCount; x++)
{
if (moduleMatrix[y][x])
{
var px = quietZoneOffset + (x * pixelsPerModule);
var py = quietZoneOffset + (y * pixelsPerModule);
// Check if this is part of a finder pattern (eyes)
var isEye = IsFinderPattern(x, y, moduleCount);
if (isEye)
{
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
}
else
{
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
}
}
}
}
// Encode to PNG
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
var qrBytes = data.ToArray();
// If no logo, return the QR code as-is
if (logoStream == null)
{
return qrBytes;
}
// Overlay logo on QR code
return OverlayLogo(qrBytes, logoStream, actualSize);
}
private static bool IsFinderPattern(int x, int y, int moduleCount)
{
// Top-left finder pattern: 0-6, 0-6
if (x <= 6 && y <= 6) return true;
// Top-right finder pattern: moduleCount-7 to moduleCount-1, 0-6
if (x >= moduleCount - 7 && y <= 6) return true;
// Bottom-left finder pattern: 0-6, moduleCount-7 to moduleCount-1
if (x <= 6 && y >= moduleCount - 7) return true;
return false;
}
private static void DrawModule(SKCanvas canvas, float x, float y, float size, SKPaint paint, string shape)
{
var padding = size * 0.1f; // 10% padding between modules
var moduleSize = size - padding;
switch (shape.ToLowerInvariant())
{
case "circle":
case "dots":
var radius = moduleSize / 2;
canvas.DrawCircle(x + size / 2, y + size / 2, radius, paint);
break;
case "rounded":
var cornerRadius = moduleSize * 0.3f;
var rect = new SKRoundRect(
new SKRect(x + padding / 2, y + padding / 2, x + size - padding / 2, y + size - padding / 2),
cornerRadius
);
canvas.DrawRoundRect(rect, paint);
break;
case "square":
default:
canvas.DrawRect(x + padding / 2, y + padding / 2, moduleSize, moduleSize, paint);
break;
}
} }
public string GenerateSvg(string content, QRCodeStyle style, int size = 512) public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
@@ -37,30 +132,120 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel); var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel); using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
using var qrCode = new SvgQRCode(qrCodeData); var moduleMatrix = qrCodeData.ModuleMatrix;
var moduleCount = moduleMatrix.Count;
// Calculate pixels per module based on desired size (accounting for quiet zone)
var totalModules = moduleCount + (style.QuietZone * 2);
var pixelsPerModule = (float)size / totalModules;
var actualSize = size;
var foreground = style.ForegroundColor; var foreground = style.ForegroundColor;
var background = style.BackgroundColor; var background = style.BackgroundColor;
// Calculate pixels per module var svg = new System.Text.StringBuilder();
var moduleCount = qrCodeData.ModuleMatrix.Count; svg.AppendLine($"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
var pixelsPerModule = Math.Max(1, size / moduleCount); svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
return qrCode.GetGraphic( var quietZoneOffset = style.QuietZone * pixelsPerModule;
pixelsPerModule,
foreground, for (int y = 0; y < moduleCount; y++)
background, {
drawQuietZones: style.QuietZone > 0 for (int x = 0; x < moduleCount; x++)
); {
if (moduleMatrix[y][x])
{
var px = quietZoneOffset + (x * pixelsPerModule);
var py = quietZoneOffset + (y * pixelsPerModule);
var isEye = IsFinderPattern(x, y, moduleCount);
var shape = isEye ? style.EyeShape : style.ModuleShape;
var padding = pixelsPerModule * 0.1f;
var moduleSize = pixelsPerModule - padding;
switch (shape.ToLowerInvariant())
{
case "circle":
case "dots":
var radius = moduleSize / 2;
var cx = px + pixelsPerModule / 2;
var cy = py + pixelsPerModule / 2;
svg.AppendLine($" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
break;
case "rounded":
var cornerRadius = moduleSize * 0.3f;
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
break;
case "square":
default:
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
break;
}
}
}
} }
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256) svg.AppendLine("</svg>");
return svg.ToString();
}
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
{ {
var pngBytes = GeneratePng(content, style, size); var pngBytes = GeneratePng(content, style, size, logoStream);
var base64 = Convert.ToBase64String(pngBytes); var base64 = Convert.ToBase64String(pngBytes);
return $"data:image/png;base64,{base64}"; return $"data:image/png;base64,{base64}";
} }
private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
{
using var qrBitmap = SKBitmap.Decode(qrBytes);
using var logoBitmap = SKBitmap.Decode(logoStream);
if (qrBitmap == null || logoBitmap == null)
{
return qrBytes;
}
// Logo should be about 20% of QR code size
var logoSize = (int)(qrSize * 0.2);
var logoX = (qrBitmap.Width - logoSize) / 2;
var logoY = (qrBitmap.Height - logoSize) / 2;
// Create a new surface to draw on
using var surface = SKSurface.Create(new SKImageInfo(qrBitmap.Width, qrBitmap.Height));
var canvas = surface.Canvas;
// Draw QR code
canvas.DrawBitmap(qrBitmap, 0, 0);
// Draw white background circle for logo
var circlePaint = new SKPaint
{
Color = SKColors.White,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
var circleRadius = logoSize * 0.6f;
canvas.DrawCircle(qrBitmap.Width / 2f, qrBitmap.Height / 2f, circleRadius, circlePaint);
// Resize and draw logo
using var resizedLogo = logoBitmap.Resize(
new SKImageInfo(logoSize, logoSize),
new SKSamplingOptions(SKCubicResampler.Mitchell));
if (resizedLogo != null)
{
canvas.DrawBitmap(resizedLogo, logoX, logoY);
}
// Encode to PNG
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
return data.ToArray();
}
private static QRCodeGenerator.ECCLevel ParseEccLevel(string level) private static QRCodeGenerator.ECCLevel ParseEccLevel(string level)
{ {
return level.ToUpperInvariant() switch return level.ToUpperInvariant() switch
@@ -73,22 +258,20 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService
}; };
} }
private static byte[] ParseColor(string hexColor) private static SKColor ParseSkColor(string hexColor)
{ {
// Remove # if present // Remove # if present
var hex = hexColor.TrimStart('#'); var hex = hexColor.TrimStart('#');
if (hex.Length == 6) if (hex.Length == 6)
{ {
return var r = Convert.ToByte(hex[..2], 16);
[ var g = Convert.ToByte(hex[2..4], 16);
Convert.ToByte(hex[..2], 16), var b = Convert.ToByte(hex[4..6], 16);
Convert.ToByte(hex[2..4], 16), return new SKColor(r, g, b);
Convert.ToByte(hex[4..6], 16)
];
} }
// Default to black // Default to black
return [0, 0, 0]; return SKColors.Black;
} }
} }

View File

@@ -10,6 +10,7 @@ namespace api.Features.Redirect.Endpoints;
public class RedirectRequest public class RedirectRequest
{ {
public string Slug { get; set; } = string.Empty; public string Slug { get; set; } = string.Empty;
public Guid? Qr { get; set; }
} }
public class RedirectResponse public class RedirectResponse
@@ -24,6 +25,7 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
{ {
Get("/{Slug}"); Get("/{Slug}");
AllowAnonymous(); AllowAnonymous();
Options(x => x.RequireRateLimiting("redirect"));
} }
public override async Task HandleAsync(RedirectRequest req, CancellationToken ct) public override async Task HandleAsync(RedirectRequest req, CancellationToken ct)
@@ -73,8 +75,16 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
return; return;
} }
// Track click event asynchronously (fire and forget) // Track event asynchronously (fire and forget)
// If qr parameter is present, track as scan; otherwise track as click
if (req.Qr.HasValue)
{
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
}
else
{
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext); await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
}
// Redirect to destination (302 Found) // Redirect to destination (302 Found)
HttpContext.Response.StatusCode = StatusCodes.Status302Found; HttpContext.Response.StatusCode = StatusCodes.Status302Found;

View File

@@ -1,5 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using api.Data; using api.Data;
using api.Features.Auth.Common;
using api.Features.Plans.Services;
using api.Features.Workspaces.Common; using api.Features.Workspaces.Common;
using api.Models; using api.Models;
using FastEndpoints; using FastEndpoints;
@@ -22,7 +24,7 @@ public class CreateWorkspaceValidator : Validator<CreateWorkspaceRequest>
} }
} }
public class CreateWorkspaceEndpoint(AppDbContext db) public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLimits)
: Endpoint<CreateWorkspaceRequest, WorkspaceResponse> : Endpoint<CreateWorkspaceRequest, WorkspaceResponse>
{ {
public override void Configure() public override void Configure()
@@ -34,6 +36,16 @@ public class CreateWorkspaceEndpoint(AppDbContext db)
{ {
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Check plan limits
if (!await planLimits.CanCreateWorkspaceAsync(userId, ct))
{
await HttpContext.Response.SendAsync(
new MessageResponse("Workspace limit reached. Please upgrade your plan to create more workspaces."),
402,
cancellation: ct);
return;
}
var workspace = new Workspace var workspace = new Workspace
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),

View File

@@ -0,0 +1,55 @@
using System.Net;
using System.Text.Json;
namespace api.Middleware;
public class GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var (statusCode, message) = exception switch
{
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Unauthorized access"),
KeyNotFoundException => (HttpStatusCode.NotFound, "Resource not found"),
ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message),
_ => (HttpStatusCode.InternalServerError, "An unexpected error occurred")
};
// Log the exception
if (statusCode == HttpStatusCode.InternalServerError)
{
logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
}
else
{
logger.LogWarning("Request error: {StatusCode} - {Message}", (int)statusCode, message);
}
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)statusCode;
var response = new ErrorResponse(
StatusCode: (int)statusCode,
Message: message,
TraceId: context.TraceIdentifier
);
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
await context.Response.WriteAsync(JsonSerializer.Serialize(response, options));
}
}
public record ErrorResponse(int StatusCode, string Message, string TraceId);

View File

@@ -0,0 +1,708 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using api.Data;
#nullable disable
namespace api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260130185641_AddQRCodeNameAndLogo")]
partial class AddQRCodeNameAndLogo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("KeyPrefix")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<List<string>>("Scopes")
.HasColumnType("text[]");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Mime")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Size")
.HasColumnType("bigint");
b.Property<string>("StorageKey")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Assets");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Hostname")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("VerificationToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Hostname")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("Domains");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("EmailVerificationTokens");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<string>("DedupeKey")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("DeviceType")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("IpHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("QRCodeId")
.HasColumnType("uuid");
b.Property<string>("Referrer")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("ShortLinkId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("QRCodeId");
b.HasIndex("Timestamp");
b.HasIndex("ShortLinkId", "Timestamp");
b.HasIndex("WorkspaceId", "Timestamp");
b.ToTable("Events");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Used")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Projects");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("LogoAssetId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("ShortLinkId")
.HasColumnType("uuid");
b.Property<string>("StyleJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("LogoAssetId");
b.HasIndex("ProjectId");
b.HasIndex("ShortLinkId");
b.HasIndex("WorkspaceId");
b.ToTable("QrCodeDesigns");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("DomainId")
.HasColumnType("uuid");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.HasIndex("DomainId", "Slug")
.IsUnique();
b.ToTable("ShortLinks");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeCustomerId")
.HasColumnType("text");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text");
b.Property<DateTime?>("SubscriptionEndsAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces");
});
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Assets")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Domains")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.HasOne("api.Models.QRCodeDesign", "QRCode")
.WithMany("Events")
.HasForeignKey("QRCodeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("Events")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Events")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("QRCode");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Projects")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.HasOne("api.Models.Asset", "LogoAsset")
.WithMany()
.HasForeignKey("LogoAssetId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("QRCodeDesigns")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("QRCodeDesigns")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("QRCodeDesigns")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LogoAsset");
b.Navigation("Project");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.HasOne("api.Models.Domain", "Domain")
.WithMany("ShortLinks")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("ShortLinks")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("ShortLinks")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Domain");
b.Navigation("Project");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.HasOne("api.Models.User", "Owner")
.WithMany("Workspaces")
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Navigation("Events");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Navigation("Events");
b.Navigation("QRCodeDesigns");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Navigation("Workspaces");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Navigation("Assets");
b.Navigation("Domains");
b.Navigation("Events");
b.Navigation("Projects");
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace api.Migrations
{
/// <inheritdoc />
public partial class AddQRCodeNameAndLogo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StripeSubscriptionId",
table: "Workspaces",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "SubscriptionEndsAt",
table: "Workspaces",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StripeCustomerId",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletedAt",
table: "ShortLinks",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Name",
table: "QrCodeDesigns",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "ApiKeys",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
KeyHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
KeyPrefix = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Scopes = table.Column<List<string>>(type: "text[]", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Id);
table.ForeignKey(
name: "FK_ApiKeys_Workspaces_WorkspaceId",
column: x => x.WorkspaceId,
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "EmailVerificationTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_EmailVerificationTokens", x => x.Id);
table.ForeignKey(
name: "FK_EmailVerificationTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PasswordResetTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Used = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_PasswordResetTokens", x => x.Id);
table.ForeignKey(
name: "FK_PasswordResetTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_KeyHash",
table: "ApiKeys",
column: "KeyHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_WorkspaceId",
table: "ApiKeys",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_EmailVerificationTokens_Token",
table: "EmailVerificationTokens",
column: "Token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_EmailVerificationTokens_UserId",
table: "EmailVerificationTokens",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_PasswordResetTokens_Token",
table: "PasswordResetTokens",
column: "Token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PasswordResetTokens_UserId",
table: "PasswordResetTokens",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApiKeys");
migrationBuilder.DropTable(
name: "EmailVerificationTokens");
migrationBuilder.DropTable(
name: "PasswordResetTokens");
migrationBuilder.DropColumn(
name: "StripeSubscriptionId",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "SubscriptionEndsAt",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "StripeCustomerId",
table: "Users");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "ShortLinks");
migrationBuilder.DropColumn(
name: "Name",
table: "QrCodeDesigns");
}
}
}

View File

@@ -0,0 +1,711 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using api.Data;
#nullable disable
namespace api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260130193730_AddProjectDescription")]
partial class AddProjectDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("KeyPrefix")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<List<string>>("Scopes")
.HasColumnType("text[]");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Mime")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("Size")
.HasColumnType("bigint");
b.Property<string>("StorageKey")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Assets");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Hostname")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("VerificationToken")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Hostname")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("Domains");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("EmailVerificationTokens");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<string>("DedupeKey")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("DeviceType")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("IpHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("QRCodeId")
.HasColumnType("uuid");
b.Property<string>("Referrer")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("ShortLinkId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("QRCodeId");
b.HasIndex("Timestamp");
b.HasIndex("ShortLinkId", "Timestamp");
b.HasIndex("WorkspaceId", "Timestamp");
b.ToTable("Events");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Used")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.ToTable("Projects");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("LogoAssetId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid?>("ShortLinkId")
.HasColumnType("uuid");
b.Property<string>("StyleJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("LogoAssetId");
b.HasIndex("ProjectId");
b.HasIndex("ShortLinkId");
b.HasIndex("WorkspaceId");
b.ToTable("QrCodeDesigns");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("DomainId")
.HasColumnType("uuid");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("WorkspaceId");
b.HasIndex("DomainId", "Slug")
.IsUnique();
b.ToTable("ShortLinks");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeCustomerId")
.HasColumnType("text");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text");
b.Property<DateTime?>("SubscriptionEndsAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces");
});
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Asset", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Assets")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Domains")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Event", b =>
{
b.HasOne("api.Models.QRCodeDesign", "QRCode")
.WithMany("Events")
.HasForeignKey("QRCodeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("Events")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Events")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("QRCode");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Projects")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.HasOne("api.Models.Asset", "LogoAsset")
.WithMany()
.HasForeignKey("LogoAssetId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("QRCodeDesigns")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("QRCodeDesigns")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("QRCodeDesigns")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LogoAsset");
b.Navigation("Project");
b.Navigation("ShortLink");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.HasOne("api.Models.Domain", "Domain")
.WithMany("ShortLinks")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Project", "Project")
.WithMany("ShortLinks")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("ShortLinks")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Domain");
b.Navigation("Project");
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.HasOne("api.Models.User", "Owner")
.WithMany("Workspaces")
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.Project", b =>
{
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Navigation("Events");
});
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Navigation("Events");
b.Navigation("QRCodeDesigns");
});
modelBuilder.Entity("api.Models.User", b =>
{
b.Navigation("Workspaces");
});
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Navigation("Assets");
b.Navigation("Domains");
b.Navigation("Events");
b.Navigation("Projects");
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace api.Migrations
{
/// <inheritdoc />
public partial class AddProjectDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
table: "Projects",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
table: "Projects");
}
}
}

View File

@@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -22,6 +23,57 @@ namespace api.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("KeyPrefix")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<List<string>>("Scopes")
.HasColumnType("text[]");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("WorkspaceId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("api.Models.Asset", b => modelBuilder.Entity("api.Models.Asset", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -100,6 +152,38 @@ namespace api.Migrations
b.ToTable("Domains"); b.ToTable("Domains");
}); });
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("EmailVerificationTokens");
});
modelBuilder.Entity("api.Models.Event", b => modelBuilder.Entity("api.Models.Event", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -164,6 +248,41 @@ namespace api.Migrations
b.ToTable("Events"); b.ToTable("Events");
}); });
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("Used")
.HasColumnType("boolean");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("api.Models.Project", b => modelBuilder.Entity("api.Models.Project", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -175,6 +294,9 @@ namespace api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -204,6 +326,10 @@ namespace api.Migrations
b.Property<Guid?>("LogoAssetId") b.Property<Guid?>("LogoAssetId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId") b.Property<Guid?>("ProjectId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -246,6 +372,9 @@ namespace api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationUrl") b.Property<string>("DestinationUrl")
.IsRequired() .IsRequired()
.HasMaxLength(2048) .HasMaxLength(2048)
@@ -319,6 +448,9 @@ namespace api.Migrations
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("character varying(255)"); .HasColumnType("character varying(255)");
b.Property<string>("StripeCustomerId")
.HasColumnType("text");
b.Property<DateTime?>("VerifiedAt") b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -354,6 +486,12 @@ namespace api.Migrations
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("character varying(20)"); .HasColumnType("character varying(20)");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text");
b.Property<DateTime?>("SubscriptionEndsAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
@@ -361,6 +499,17 @@ namespace api.Migrations
b.ToTable("Workspaces"); b.ToTable("Workspaces");
}); });
modelBuilder.Entity("api.Models.ApiKey", b =>
{
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany()
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Workspace");
});
modelBuilder.Entity("api.Models.Asset", b => modelBuilder.Entity("api.Models.Asset", b =>
{ {
b.HasOne("api.Models.Workspace", "Workspace") b.HasOne("api.Models.Workspace", "Workspace")
@@ -383,6 +532,17 @@ namespace api.Migrations
b.Navigation("Workspace"); b.Navigation("Workspace");
}); });
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Event", b => modelBuilder.Entity("api.Models.Event", b =>
{ {
b.HasOne("api.Models.QRCodeDesign", "QRCode") b.HasOne("api.Models.QRCodeDesign", "QRCode")
@@ -409,6 +569,17 @@ namespace api.Migrations
b.Navigation("Workspace"); b.Navigation("Workspace");
}); });
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
{
b.HasOne("api.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("api.Models.Project", b => modelBuilder.Entity("api.Models.Project", b =>
{ {
b.HasOne("api.Models.Workspace", "Workspace") b.HasOne("api.Models.Workspace", "Workspace")

17
src/api/Models/ApiKey.cs Normal file
View File

@@ -0,0 +1,17 @@
namespace api.Models;
public class ApiKey
{
public Guid Id { get; set; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public required string KeyHash { get; set; } // Only store hash, never the raw key
public required string KeyPrefix { get; set; } // First 8 chars for identification (e.g., "trk_abc1...")
public DateTime CreatedAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsActive { get; set; } = true;
public List<string>? Scopes { get; set; } // e.g., ["links:read", "links:write", "qrcodes:read"]
public Workspace Workspace { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
namespace api.Models;
public class EmailVerificationToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public required string Token { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,14 @@
namespace api.Models;
public class PasswordResetToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public required string Token { get; set; }
public DateTime ExpiresAt { get; set; }
public bool Used { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation
public User User { get; set; } = null!;
}

View File

@@ -5,6 +5,7 @@ public class Project
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
// Navigation properties // Navigation properties

View File

@@ -6,6 +6,7 @@ public class QRCodeDesign
public Guid WorkspaceId { get; set; } public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; } public Guid? ProjectId { get; set; }
public Guid? ShortLinkId { get; set; } public Guid? ShortLinkId { get; set; }
public required string Name { get; set; }
public required string StyleJson { get; set; } public required string StyleJson { get; set; }
public Guid? LogoAssetId { get; set; } public Guid? LogoAssetId { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }

View File

@@ -20,6 +20,7 @@ public class ShortLink
public string? PasswordHash { get; set; } public string? PasswordHash { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; } // Soft delete
// Navigation properties // Navigation properties
public Workspace Workspace { get; set; } = null!; public Workspace Workspace { get; set; } = null!;

View File

@@ -5,6 +5,7 @@ public class User
public Guid Id { get; set; } public Guid Id { get; set; }
public required string Email { get; set; } public required string Email { get; set; }
public required string PasswordHash { get; set; } public required string PasswordHash { get; set; }
public string? StripeCustomerId { get; set; }
public DateTime? VerifiedAt { get; set; } public DateTime? VerifiedAt { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }

View File

@@ -13,6 +13,8 @@ public class Workspace
public Guid OwnerUserId { get; set; } public Guid OwnerUserId { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public WorkspacePlan Plan { get; set; } = WorkspacePlan.Free; public WorkspacePlan Plan { get; set; } = WorkspacePlan.Free;
public string? StripeSubscriptionId { get; set; }
public DateTime? SubscriptionEndsAt { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
// Navigation properties // Navigation properties

View File

@@ -1,39 +1,152 @@
using System.Text; using System.Text;
using System.Threading.RateLimiting;
using api.Data; using api.Data;
using api.Features.Auth.Settings; using api.Features.Auth.Settings;
using api.Features.Events.Services; using api.Features.Events.Services;
using api.Features.Assets.Services; using api.Features.Assets.Services;
using api.Features.Email.Services;
using api.Features.Billing.Services;
using api.Features.Billing.Settings;
using api.Features.Plans.Services;
using api.Features.QRCodes.Services; using api.Features.QRCodes.Services;
using api.Middleware;
using FastEndpoints; using FastEndpoints;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Serilog;
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/api-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
.CreateLogger();
try
{
Log.Information("Starting TrakQR API");
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add cors // Use Serilog
if (builder.Environment.IsDevelopment()) builder.Host.UseSerilog();
{
// Configure CORS
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
{
if (builder.Environment.IsDevelopment())
{ {
policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback) policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod(); .AllowAnyMethod()
}); .AllowCredentials();
});
} }
else
{
// Production: configure allowed origins from config
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["https://trakqr.com"];
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
});
});
// Configure Rate Limiting (skip in Testing environment)
var isTestingEnvironment = builder.Environment.EnvironmentName == "Testing";
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Add services to the container. // Use very high limits in testing environment
var authLimit = isTestingEnvironment ? 100000 : 10;
var globalLimit = isTestingEnvironment ? 100000 : 100;
var redirectLimit = isTestingEnvironment ? 100000 : 1000;
var apiLimit = isTestingEnvironment ? 100000 : 200;
// Global rate limit for all endpoints
options.AddPolicy("global", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = globalLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
// Strict rate limit for authentication endpoints
options.AddPolicy("auth", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = authLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
// Higher limit for redirect endpoint (public, needs to be fast)
options.AddPolicy("redirect", context =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = redirectLimit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0
}));
// API rate limit for authenticated endpoints
options.AddPolicy("api", context =>
RateLimitPartition.GetTokenBucketLimiter(
partitionKey: context.User?.Identity?.Name ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = apiLimit,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = apiLimit,
QueueLimit = 0
}));
});
// Add services to the container
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"))); options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
// Register application services // Register application services
builder.Services.AddSingleton<IGeoIpService, GeoIpService>();
builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>(); builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>();
builder.Services.AddSingleton<IQRCodeGeneratorService, QRCodeGeneratorService>(); builder.Services.AddSingleton<IQrCodeGeneratorService, QrCodeGeneratorService>();
builder.Services.AddSingleton<IAssetStorageService, LocalAssetStorageService>(); builder.Services.AddSingleton<IAssetStorageService, LocalAssetStorageService>();
builder.Services.AddSingleton<IPlanLimitsService, PlanLimitsService>();
// Configure email service
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email"));
var emailProvider = builder.Configuration.GetValue<string>("Email:Provider") ?? "console";
if (emailProvider == "smtp")
{
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
}
else
{
// Use console email service for development
builder.Services.AddSingleton<IEmailService, ConsoleEmailService>();
}
// Configure Stripe
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.AddSingleton<IStripeService, StripeService>();
// Configure JWT settings // Configure JWT settings
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt")); builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
@@ -61,21 +174,44 @@ builder.Services.AddOpenApi();
var app = builder.Build(); var app = builder.Build();
app.UseCors(); // Global error handling middleware (must be first)
app.UseMiddleware<GlobalExceptionMiddleware>();
// Configure the HTTP request pipeline. // Request logging middleware
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString());
};
});
app.UseCors();
app.UseRateLimiter();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi().CacheOutput(); app.MapOpenApi().CacheOutput();
app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); }); app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseFastEndpoints(); app.UseFastEndpoints();
app.Run(); app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FastEndpoints" Version="7.2.0" /> <PackageReference Include="FastEndpoints" Version="7.2.0" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
@@ -21,6 +22,12 @@
</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="QRCoder" Version="1.7.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.116.1" />
<PackageReference Include="Stripe.net" Version="47.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.0" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.0" />
</ItemGroup> </ItemGroup>

View File

@@ -9,6 +9,24 @@
"PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!" "PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!"
}, },
"Jwt": { "Jwt": {
"Secret": "dev-secret-key-min-32-characters-long-for-hmac256!" "Secret": "dev-secret-key-min-32-characters-long-for-hmac256!",
"Issuer": "TrakQR",
"Audience": "TrakQR",
"ExpirationMinutes": 60
},
"Email": {
"Provider": "console",
"FromEmail": "noreply@trakqr.local",
"FromName": "TrakQR",
"BaseUrl": "http://localhost:5173"
},
"Cors": {
"AllowedOrigins": ["http://localhost:5173", "https://localhost:5173"]
},
"Stripe": {
"SecretKey": "sk_test_your_test_key_here",
"WebhookSecret": "whsec_your_webhook_secret_here",
"ProPriceId": "price_pro_monthly",
"BusinessPriceId": "price_business_monthly"
} }
} }

View File

@@ -14,5 +14,30 @@
"Issuer": "TrakQR", "Issuer": "TrakQR",
"Audience": "TrakQR", "Audience": "TrakQR",
"ExpirationMinutes": 60 "ExpirationMinutes": 60
},
"Email": {
"Provider": "smtp",
"FromEmail": "noreply@trakqr.com",
"FromName": "TrakQR",
"BaseUrl": "https://trakqr.com",
"Smtp": {
"Host": "",
"Port": 587,
"UseSsl": true,
"Username": "",
"Password": ""
}
},
"Cors": {
"AllowedOrigins": ["https://trakqr.com"]
},
"GeoIP": {
"DatabasePath": ""
},
"Stripe": {
"SecretKey": "",
"WebhookSecret": "",
"ProPriceId": "",
"BusinessPriceId": ""
} }
} }

View File

@@ -1,3 +1,22 @@
<template> <template>
<router-view /> <router-view />
</template> </template>
<script setup>
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import { useWorkspaceStore } from './stores/workspace';
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
onMounted(async () => {
// Initialize auth first
await authStore.initialize();
// If authenticated, initialize workspace
if (authStore.isAuthenticated) {
await workspaceStore.initialize();
}
});
</script>

View File

@@ -77,6 +77,38 @@ class ApiClient {
return this.request('POST', '/auth/login', { email, password }); return this.request('POST', '/auth/login', { email, password });
} }
forgotPassword(email) {
return this.request('POST', '/auth/forgot', { email });
}
resetPassword(token, newPassword) {
return this.request('POST', '/auth/reset', { token, newPassword });
}
getProfile() {
return this.request('GET', '/auth/profile');
}
updateProfile(data) {
return this.request('PUT', '/auth/profile', data);
}
changePassword(currentPassword, newPassword) {
return this.request('POST', '/auth/change-password', { currentPassword, newPassword });
}
resendVerification() {
return this.request('POST', '/auth/resend-verification');
}
verifyEmail(token) {
return this.request('POST', '/auth/verify-email', { token });
}
deleteAccount(password) {
return this.request('DELETE', '/auth/account', { password });
}
// Workspaces // Workspaces
listWorkspaces() { listWorkspaces() {
return this.request('GET', '/workspaces'); return this.request('GET', '/workspaces');
@@ -126,6 +158,10 @@ class ApiClient {
return this.request('GET', path); return this.request('GET', path);
} }
restoreLink(workspaceId, id) {
return this.request('POST', `/workspaces/${workspaceId}/links/${id}/restore`);
}
createLink(workspaceId, data) { createLink(workspaceId, data) {
return this.request('POST', `/workspaces/${workspaceId}/links`, data); return this.request('POST', `/workspaces/${workspaceId}/links`, data);
} }
@@ -142,8 +178,19 @@ class ApiClient {
return this.request('DELETE', `/workspaces/${workspaceId}/links/${id}`); return this.request('DELETE', `/workspaces/${workspaceId}/links/${id}`);
} }
getLinkAnalytics(workspaceId, linkId, period = '7d') { bulkCreateLinks(workspaceId, links) {
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?period=${period}`); return this.request('POST', `/workspaces/${workspaceId}/links/bulk`, { links });
}
getLinkAnalytics(workspaceId, linkId, period = '7d', startDate = null, endDate = null) {
const params = new URLSearchParams();
if (startDate && endDate) {
params.set('startDate', startDate);
params.set('endDate', endDate);
} else {
params.set('period', period);
}
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?${params.toString()}`);
} }
// QR Codes // QR Codes
@@ -175,9 +222,20 @@ class ApiClient {
return `${API_BASE}/workspaces/${workspaceId}/qrcodes/${id}/export?format=${format}&size=${size}`; return `${API_BASE}/workspaces/${workspaceId}/qrcodes/${id}/export?format=${format}&size=${size}`;
} }
getQRCodeAnalytics(workspaceId, qrCodeId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/qrcodes/${qrCodeId}/analytics?period=${period}`);
}
// Analytics // Analytics
getWorkspaceAnalytics(workspaceId, period = '7d') { getWorkspaceAnalytics(workspaceId, period = '7d', startDate = null, endDate = null) {
return this.request('GET', `/workspaces/${workspaceId}/analytics?period=${period}`); const params = new URLSearchParams();
if (startDate && endDate) {
params.set('startDate', startDate);
params.set('endDate', endDate);
} else {
params.set('period', period);
}
return this.request('GET', `/workspaces/${workspaceId}/analytics?${params.toString()}`);
} }
// Domains // Domains
@@ -209,6 +267,43 @@ class ApiClient {
deleteAsset(workspaceId, id) { deleteAsset(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/assets/${id}`); return this.request('DELETE', `/workspaces/${workspaceId}/assets/${id}`);
} }
// Billing
createCheckoutSession(workspaceId, plan, successUrl, cancelUrl) {
return this.request('POST', '/billing/checkout', {
workspaceId,
plan,
successUrl,
cancelUrl,
});
}
createPortalSession(returnUrl) {
return this.request('POST', '/billing/portal', { returnUrl });
}
getSubscription(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/subscription`);
}
// Usage
getUsage(workspaceId = null) {
const path = workspaceId ? `/usage?workspaceId=${workspaceId}` : '/usage';
return this.request('GET', path);
}
// API Keys
listApiKeys(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/api-keys`);
}
createApiKey(workspaceId, name, expiresAt = null, scopes = null) {
return this.request('POST', `/workspaces/${workspaceId}/api-keys`, { name, expiresAt, scopes });
}
deleteApiKey(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/api-keys/${id}`);
}
} }
export const api = new ApiClient(); export const api = new ApiClient();

View File

@@ -9,6 +9,7 @@
</div> </div>
<div class="workspace-selector" v-if="workspaceStore.currentWorkspace"> <div class="workspace-selector" v-if="workspaceStore.currentWorkspace">
<div class="workspace-dropdown">
<select <select
:value="workspaceStore.currentWorkspace?.id" :value="workspaceStore.currentWorkspace?.id"
@change="onWorkspaceChange" @change="onWorkspaceChange"
@@ -22,6 +23,127 @@
{{ ws.name }} {{ ws.name }}
</option> </option>
</select> </select>
<div class="workspace-actions">
<button class="ws-action-btn" @click="showCreateWorkspace = true" title="Create workspace">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<button class="ws-action-btn" @click="showWorkspaceSettings = true" title="Workspace settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Create Workspace Modal -->
<div v-if="showCreateWorkspace" class="modal-overlay" @click.self="showCreateWorkspace = false">
<div class="modal">
<div class="modal-header">
<h2>Create Workspace</h2>
<button class="close-btn" @click="showCreateWorkspace = false">&times;</button>
</div>
<form @submit.prevent="createWorkspace">
<div class="modal-body">
<div class="form-group">
<label for="ws-name">Workspace Name</label>
<input
id="ws-name"
v-model="newWorkspaceName"
type="text"
placeholder="e.g., Marketing Team"
required
/>
</div>
<div v-if="wsError" class="error-message">{{ wsError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showCreateWorkspace = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="creatingWs">
{{ creatingWs ? 'Creating...' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
<!-- Workspace Settings Modal -->
<div v-if="showWorkspaceSettings" class="modal-overlay" @click.self="showWorkspaceSettings = false">
<div class="modal">
<div class="modal-header">
<h2>Workspace Settings</h2>
<button class="close-btn" @click="showWorkspaceSettings = false">&times;</button>
</div>
<form @submit.prevent="updateWorkspace">
<div class="modal-body">
<div class="form-group">
<label for="ws-edit-name">Workspace Name</label>
<input
id="ws-edit-name"
v-model="editWorkspaceName"
type="text"
required
/>
</div>
<div class="workspace-info">
<div class="info-row">
<span class="info-label">Plan:</span>
<span class="info-value plan-badge" :class="workspaceStore.currentWorkspace?.plan?.toLowerCase()">
{{ workspaceStore.currentWorkspace?.plan }}
</span>
</div>
<div class="info-row">
<span class="info-label">Created:</span>
<span class="info-value">{{ formatDate(workspaceStore.currentWorkspace?.createdAt) }}</span>
</div>
</div>
<div v-if="wsError" class="error-message">{{ wsError }}</div>
</div>
<div class="modal-actions split">
<button
type="button"
class="btn btn-danger-outline"
@click="confirmDeleteWorkspace"
:disabled="workspaceStore.workspaces.length <= 1"
:title="workspaceStore.workspaces.length <= 1 ? 'Cannot delete your only workspace' : 'Delete workspace'"
>
Delete
</button>
<div class="action-group">
<button type="button" class="btn btn-secondary" @click="showWorkspaceSettings = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="updatingWs">
{{ updatingWs ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Delete Workspace Confirmation -->
<div v-if="showDeleteWorkspace" class="modal-overlay" @click.self="showDeleteWorkspace = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Workspace</h2>
<button class="close-btn" @click="showDeleteWorkspace = false">&times;</button>
</div>
<div class="modal-body">
<p class="delete-warning">
Are you sure you want to delete <strong>{{ workspaceStore.currentWorkspace?.name }}</strong>?
All links, QR codes, and analytics data will be permanently deleted.
</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteWorkspace = false">Cancel</button>
<button class="btn btn-danger" @click="deleteWorkspace" :disabled="deletingWs">
{{ deletingWs ? 'Deleting...' : 'Delete Workspace' }}
</button>
</div>
</div>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -59,6 +181,34 @@
</svg> </svg>
Analytics Analytics
</router-link> </router-link>
<router-link to="/projects" class="nav-item" :class="{ active: $route.name === 'projects' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Projects
</router-link>
<router-link to="/domains" class="nav-item" :class="{ active: $route.name === 'domains' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
Domains
</router-link>
<router-link to="/billing" class="nav-item" :class="{ active: $route.name === 'billing' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
<line x1="1" y1="10" x2="23" y2="10"/>
</svg>
Billing
</router-link>
<router-link to="/settings" class="nav-item" :class="{ active: $route.name === 'settings' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</router-link>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -80,7 +230,7 @@
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue'; import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth'; import { useAuthStore } from '../../stores/auth';
import { useWorkspaceStore } from '../../stores/workspace'; import { useWorkspaceStore } from '../../stores/workspace';
@@ -89,11 +239,29 @@ const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore(); const workspaceStore = useWorkspaceStore();
// Workspace management state
const showCreateWorkspace = ref(false);
const showWorkspaceSettings = ref(false);
const showDeleteWorkspace = ref(false);
const newWorkspaceName = ref('');
const editWorkspaceName = ref('');
const wsError = ref('');
const creatingWs = ref(false);
const updatingWs = ref(false);
const deletingWs = ref(false);
onMounted(async () => { onMounted(async () => {
authStore.checkAuth(); // Ensure stores are initialized (in case component mounts before App.vue init completes)
await workspaceStore.fetchWorkspaces(); await authStore.initialize();
await workspaceStore.initialize();
}); });
watch(() => workspaceStore.currentWorkspace, (ws) => {
if (ws) {
editWorkspaceName.value = ws.name;
}
}, { immediate: true });
const onWorkspaceChange = (e) => { const onWorkspaceChange = (e) => {
const workspace = workspaceStore.workspaces.find(w => w.id === e.target.value); const workspace = workspaceStore.workspaces.find(w => w.id === e.target.value);
if (workspace) { if (workspace) {
@@ -101,7 +269,65 @@ const onWorkspaceChange = (e) => {
} }
}; };
const createWorkspace = async () => {
creatingWs.value = true;
wsError.value = '';
try {
const workspace = await workspaceStore.createWorkspace(newWorkspaceName.value);
workspaceStore.setCurrentWorkspace(workspace);
showCreateWorkspace.value = false;
newWorkspaceName.value = '';
} catch (err) {
wsError.value = err.message;
} finally {
creatingWs.value = false;
}
};
const updateWorkspace = async () => {
updatingWs.value = true;
wsError.value = '';
try {
const wsId = workspaceStore.currentWorkspace?.id;
await workspaceStore.updateWorkspace(wsId, editWorkspaceName.value);
showWorkspaceSettings.value = false;
} catch (err) {
wsError.value = err.message;
} finally {
updatingWs.value = false;
}
};
const confirmDeleteWorkspace = () => {
showWorkspaceSettings.value = false;
showDeleteWorkspace.value = true;
};
const deleteWorkspace = async () => {
deletingWs.value = true;
wsError.value = '';
try {
const wsId = workspaceStore.currentWorkspace?.id;
await workspaceStore.deleteWorkspace(wsId);
showDeleteWorkspace.value = false;
} catch (err) {
wsError.value = err.message;
} finally {
deletingWs.value = false;
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const logout = () => { const logout = () => {
workspaceStore.clearAll();
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
}; };
@@ -227,4 +453,260 @@ const logout = () => {
padding: 32px; padding: 32px;
overflow-y: auto; overflow-y: auto;
} }
/* Workspace dropdown */
.workspace-dropdown {
display: flex;
gap: 8px;
}
.workspace-dropdown .workspace-select {
flex: 1;
}
.workspace-actions {
display: flex;
gap: 4px;
}
.ws-action-btn {
width: 36px;
height: 36px;
border: 1px solid var(--line);
background: var(--bg);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
transition: all 0.15s;
}
.ws-action-btn:hover {
background: var(--surface);
color: var(--ink);
}
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 380px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.workspace-info {
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-label {
font-size: 0.875rem;
color: #6b7280;
}
.info-value {
font-size: 0.875rem;
font-weight: 500;
}
.plan-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.plan-badge.free {
background: #e5e7eb;
color: #374151;
}
.plan-badge.pro {
background: #dbeafe;
color: #1d4ed8;
}
.plan-badge.business {
background: #fef3c7;
color: #b45309;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.modal-actions.split {
justify-content: space-between;
}
.action-group {
display: flex;
gap: 0.75rem;
}
.delete-warning {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #991b1b;
line-height: 1.5;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-top: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger-outline {
background: white;
color: #dc2626;
border: 1px solid #fecaca;
}
.btn-danger-outline:hover:not(:disabled) {
background: #fef2f2;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>

View File

@@ -5,12 +5,20 @@ import { useAuthStore } from '../stores/auth';
import Landing from '../views/Landing.vue'; import Landing from '../views/Landing.vue';
import Login from '../views/auth/Login.vue'; import Login from '../views/auth/Login.vue';
import Register from '../views/auth/Register.vue'; import Register from '../views/auth/Register.vue';
import ForgotPassword from '../views/auth/ForgotPassword.vue';
import ResetPassword from '../views/auth/ResetPassword.vue';
import VerifyEmail from '../views/auth/VerifyEmail.vue';
import Dashboard from '../views/dashboard/Dashboard.vue'; import Dashboard from '../views/dashboard/Dashboard.vue';
import Links from '../views/links/Links.vue'; import Links from '../views/links/Links.vue';
import LinkDetail from '../views/links/LinkDetail.vue'; import LinkDetail from '../views/links/LinkDetail.vue';
import QRCodes from '../views/qrcodes/QRCodes.vue'; import QRCodes from '../views/qrcodes/QRCodes.vue';
import QRCodeDesigner from '../views/qrcodes/QRCodeDesigner.vue'; import QRCodeDesigner from '../views/qrcodes/QRCodeDesigner.vue';
import QRCodeDetail from '../views/qrcodes/QRCodeDetail.vue';
import Analytics from '../views/analytics/Analytics.vue'; import Analytics from '../views/analytics/Analytics.vue';
import Billing from '../views/billing/Billing.vue';
import Projects from '../views/projects/Projects.vue';
import Domains from '../views/domains/Domains.vue';
import Settings from '../views/settings/Settings.vue';
const routes = [ const routes = [
{ {
@@ -30,6 +38,23 @@ const routes = [
component: Register, component: Register,
meta: { guest: true }, meta: { guest: true },
}, },
{
path: '/forgot-password',
name: 'forgot-password',
component: ForgotPassword,
meta: { guest: true },
},
{
path: '/reset-password',
name: 'reset-password',
component: ResetPassword,
meta: { guest: true },
},
{
path: '/verify-email',
name: 'verify-email',
component: VerifyEmail,
},
{ {
path: '/dashboard', path: '/dashboard',
name: 'dashboard', name: 'dashboard',
@@ -66,12 +91,42 @@ const routes = [
component: QRCodeDesigner, component: QRCodeDesigner,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/qrcodes/:id/analytics',
name: 'qrcode-analytics',
component: QRCodeDetail,
meta: { requiresAuth: true },
},
{ {
path: '/analytics', path: '/analytics',
name: 'analytics', name: 'analytics',
component: Analytics, component: Analytics,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/billing',
name: 'billing',
component: Billing,
meta: { requiresAuth: true },
},
{
path: '/projects',
name: 'projects',
component: Projects,
meta: { requiresAuth: true },
},
{
path: '/domains',
name: 'domains',
component: Domains,
meta: { requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: Settings,
meta: { requiresAuth: true },
},
]; ];
const router = createRouter({ const router = createRouter({

View File

@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', {
token: localStorage.getItem('token'), token: localStorage.getItem('token'),
loading: false, loading: false,
error: null, error: null,
initialized: false,
}), }),
getters: { getters: {
@@ -14,13 +15,30 @@ export const useAuthStore = defineStore('auth', {
}, },
actions: { actions: {
async initialize() {
if (this.initialized) return;
if (this.token) {
api.setToken(this.token);
try {
const profile = await api.getProfile();
this.user = profile;
} catch (err) {
// Token is invalid, clear it
this.logout();
}
}
this.initialized = true;
},
async register(email, password) { async register(email, password) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try {
const response = await api.register(email, password); const response = await api.register(email, password);
this.token = response.token; this.token = response.token;
this.user = { email: response.email }; this.user = { email: response.email, isVerified: false };
localStorage.setItem('token', response.token);
api.setToken(response.token); api.setToken(response.token);
return true; return true;
} catch (err) { } catch (err) {
@@ -38,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
const response = await api.login(email, password); const response = await api.login(email, password);
this.token = response.token; this.token = response.token;
this.user = { email: response.email }; this.user = { email: response.email };
localStorage.setItem('token', response.token);
api.setToken(response.token); api.setToken(response.token);
return true; return true;
} catch (err) { } catch (err) {
@@ -51,15 +70,17 @@ export const useAuthStore = defineStore('auth', {
logout() { logout() {
this.token = null; this.token = null;
this.user = null; this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('currentWorkspaceId');
api.setToken(null); api.setToken(null);
}, },
checkAuth() { async fetchProfile() {
if (this.token) { try {
api.setToken(this.token); this.user = await api.getProfile();
return true; } catch (err) {
this.error = err.message;
} }
return false;
}, },
}, },
}); });

View File

@@ -8,23 +8,44 @@ export const useWorkspaceStore = defineStore('workspace', {
projects: [], projects: [],
links: [], links: [],
qrcodes: [], qrcodes: [],
domains: [],
assets: [],
analytics: null, analytics: null,
loading: false, loading: false,
error: null, error: null,
initialized: false,
}), }),
getters: { getters: {
currentWorkspaceId: (state) => state.currentWorkspace?.id, currentWorkspaceId: (state) => state.currentWorkspace?.id,
currentPlan: (state) => state.currentWorkspace?.plan || 'Free',
canUseCustomDomains: (state) => {
const plan = state.currentWorkspace?.plan;
return plan === 'Pro' || plan === 'Business';
},
}, },
actions: { actions: {
async initialize() {
if (this.initialized) return;
await this.fetchWorkspaces();
this.initialized = true;
},
async fetchWorkspaces() { async fetchWorkspaces() {
this.loading = true; this.loading = true;
try { try {
const response = await api.listWorkspaces(); const response = await api.listWorkspaces();
this.workspaces = response.workspaces; this.workspaces = response.workspaces || [];
if (!this.currentWorkspace && this.workspaces.length > 0) {
this.currentWorkspace = this.workspaces[0]; // Restore saved workspace or use first one
const savedId = localStorage.getItem('currentWorkspaceId');
const saved = savedId ? this.workspaces.find(w => w.id === savedId) : null;
if (saved) {
this.currentWorkspace = saved;
} else if (this.workspaces.length > 0) {
this.setCurrentWorkspace(this.workspaces[0]);
} }
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
@@ -35,9 +56,17 @@ export const useWorkspaceStore = defineStore('workspace', {
setCurrentWorkspace(workspace) { setCurrentWorkspace(workspace) {
this.currentWorkspace = workspace; this.currentWorkspace = workspace;
if (workspace) {
localStorage.setItem('currentWorkspaceId', workspace.id);
} else {
localStorage.removeItem('currentWorkspaceId');
}
// Clear workspace-specific data
this.projects = []; this.projects = [];
this.links = []; this.links = [];
this.qrcodes = []; this.qrcodes = [];
this.domains = [];
this.assets = [];
this.analytics = null; this.analytics = null;
}, },
@@ -52,12 +81,42 @@ export const useWorkspaceStore = defineStore('workspace', {
} }
}, },
async updateWorkspace(id, name) {
try {
const updated = await api.updateWorkspace(id, name);
const index = this.workspaces.findIndex(w => w.id === id);
if (index !== -1) {
this.workspaces[index] = updated;
}
if (this.currentWorkspace?.id === id) {
this.currentWorkspace = updated;
}
return updated;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteWorkspace(id) {
try {
await api.deleteWorkspace(id);
this.workspaces = this.workspaces.filter(w => w.id !== id);
if (this.currentWorkspace?.id === id) {
this.setCurrentWorkspace(this.workspaces[0] || null);
}
} catch (err) {
this.error = err.message;
throw err;
}
},
// Projects // Projects
async fetchProjects() { async fetchProjects() {
if (!this.currentWorkspaceId) return; if (!this.currentWorkspaceId) return;
try { try {
const response = await api.listProjects(this.currentWorkspaceId); const response = await api.listProjects(this.currentWorkspaceId);
this.projects = response.projects; this.projects = response.projects || [];
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
} }
@@ -67,7 +126,22 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return; if (!this.currentWorkspaceId) return;
try { try {
const project = await api.createProject(this.currentWorkspaceId, name, description); const project = await api.createProject(this.currentWorkspaceId, name, description);
this.projects.push(project); this.projects.unshift(project);
return project;
} catch (err) {
this.error = err.message;
throw err;
}
},
async updateProject(id, data) {
if (!this.currentWorkspaceId) return;
try {
const project = await api.updateProject(this.currentWorkspaceId, id, data);
const index = this.projects.findIndex(p => p.id === id);
if (index !== -1) {
this.projects[index] = project;
}
return project; return project;
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
@@ -91,7 +165,7 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return; if (!this.currentWorkspaceId) return;
try { try {
const response = await api.listLinks(this.currentWorkspaceId, params); const response = await api.listLinks(this.currentWorkspaceId, params);
this.links = response.links; this.links = response.links || [];
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
} }
@@ -140,7 +214,7 @@ export const useWorkspaceStore = defineStore('workspace', {
if (!this.currentWorkspaceId) return; if (!this.currentWorkspaceId) return;
try { try {
const response = await api.listQRCodes(this.currentWorkspaceId); const response = await api.listQRCodes(this.currentWorkspaceId);
this.qrcodes = response.qrCodes; this.qrcodes = response.qrCodes || [];
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
} }
@@ -184,14 +258,109 @@ export const useWorkspaceStore = defineStore('workspace', {
} }
}, },
// Analytics // Domains
async fetchAnalytics(period = '7d') { async fetchDomains() {
if (!this.currentWorkspaceId) return; if (!this.currentWorkspaceId) return;
try { try {
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period); const response = await api.listDomains(this.currentWorkspaceId);
this.domains = response.domains || [];
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
} }
}, },
async addDomain(hostname) {
if (!this.currentWorkspaceId) return;
try {
const domain = await api.addDomain(this.currentWorkspaceId, hostname);
this.domains.unshift(domain);
return domain;
} catch (err) {
this.error = err.message;
throw err;
}
},
async verifyDomain(id) {
if (!this.currentWorkspaceId) return;
try {
const result = await api.verifyDomain(this.currentWorkspaceId, id);
// Refresh domains to get updated status
await this.fetchDomains();
return result;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteDomain(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteDomain(this.currentWorkspaceId, id);
this.domains = this.domains.filter(d => d.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Assets
async fetchAssets() {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listAssets(this.currentWorkspaceId);
this.assets = response.assets || [];
} catch (err) {
this.error = err.message;
}
},
async uploadAsset(file) {
if (!this.currentWorkspaceId) return;
try {
const asset = await api.uploadAsset(this.currentWorkspaceId, file);
this.assets.unshift(asset);
return asset;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteAsset(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteAsset(this.currentWorkspaceId, id);
this.assets = this.assets.filter(a => a.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Analytics
async fetchAnalytics(period = '7d', startDate = null, endDate = null) {
if (!this.currentWorkspaceId) return;
try {
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period, startDate, endDate);
} catch (err) {
this.error = err.message;
}
},
// Clear all data (for logout)
clearAll() {
this.workspaces = [];
this.currentWorkspace = null;
this.projects = [];
this.links = [];
this.qrcodes = [];
this.domains = [];
this.assets = [];
this.analytics = null;
this.initialized = false;
localStorage.removeItem('currentWorkspaceId');
},
}, },
}); });

View File

@@ -6,15 +6,28 @@
<h1>Analytics</h1> <h1>Analytics</h1>
<p class="subtitle">Track performance across your workspace</p> <p class="subtitle">Track performance across your workspace</p>
</div> </div>
<div class="period-controls">
<div class="period-selector"> <div class="period-selector">
<button <button
v-for="p in periods" v-for="p in periods"
:key="p.value" :key="p.value"
:class="{ active: period === p.value }" :class="{ active: period === p.value && !isCustomRange }"
@click="setPeriod(p.value)" @click="setPeriod(p.value)"
> >
{{ p.label }} {{ p.label }}
</button> </button>
<button
:class="{ active: isCustomRange }"
@click="toggleCustomRange"
>
Custom
</button>
</div>
<div v-if="isCustomRange" class="date-range">
<input type="date" v-model="startDate" @change="applyCustomRange" />
<span>to</span>
<input type="date" v-model="endDate" @change="applyCustomRange" />
</div>
</div> </div>
</header> </header>
@@ -198,13 +211,34 @@ const periods = [
]; ];
const period = ref('7d'); const period = ref('7d');
const isCustomRange = ref(false);
const startDate = ref('');
const endDate = ref('');
const analytics = computed(() => workspaceStore.analytics); const analytics = computed(() => workspaceStore.analytics);
const setPeriod = async (p) => { const setPeriod = async (p) => {
isCustomRange.value = false;
period.value = p; period.value = p;
await workspaceStore.fetchAnalytics(p); await workspaceStore.fetchAnalytics(p);
}; };
const toggleCustomRange = () => {
isCustomRange.value = true;
// Set default range to last 30 days
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 30);
startDate.value = start.toISOString().split('T')[0];
endDate.value = end.toISOString().split('T')[0];
applyCustomRange();
};
const applyCustomRange = async () => {
if (startDate.value && endDate.value) {
await workspaceStore.fetchAnalytics(null, startDate.value, endDate.value);
}
};
const maxEvents = computed(() => { const maxEvents = computed(() => {
if (!analytics.value?.timeSeries) return 1; if (!analytics.value?.timeSeries) return 1;
return Math.max(...analytics.value.timeSeries.map(p => Math.max(p.clicks, p.scans)), 1); return Math.max(...analytics.value.timeSeries.map(p => Math.max(p.clicks, p.scans)), 1);
@@ -264,6 +298,13 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: var(--muted); color: var(--muted);
} }
.period-controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.period-selector { .period-selector {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -290,6 +331,36 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: white; color: white;
} }
.date-range {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface);
padding: 8px 12px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.date-range input[type="date"] {
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
background: var(--bg);
color: var(--ink);
}
.date-range input[type="date"]:focus {
outline: none;
border-color: var(--accent);
}
.date-range span {
color: var(--muted);
font-size: 0.875rem;
}
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);

View File

@@ -0,0 +1,215 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Reset your password</h1>
<p>Enter your email and we'll send you a reset link</p>
</div>
<form v-if="!submitted" @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="cta full" :disabled="loading">
{{ loading ? 'Sending...' : 'Send reset link' }}
</button>
</form>
<div v-else class="success-message">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Check your email</h2>
<p>If an account exists for {{ email }}, we've sent password reset instructions.</p>
<router-link to="/login" class="cta full">Back to login</router-link>
</div>
<p class="auth-footer" v-if="!submitted">
Remember your password?
<router-link to="/login">Sign in</router-link>
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { api } from '../../api/client';
const email = ref('');
const loading = ref(false);
const error = ref('');
const submitted = ref(false);
const handleSubmit = async () => {
loading.value = true;
error.value = '';
try {
await api.forgotPassword(email.value);
submitted.value = true;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.success-message {
text-align: center;
}
.success-icon {
color: #22c55e;
margin-bottom: 16px;
}
.success-message h2 {
margin-bottom: 12px;
}
.success-message p {
color: var(--muted);
margin-bottom: 24px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: 24px;
color: var(--muted);
}
.auth-footer a {
color: var(--accent);
font-weight: 500;
}
</style>

View File

@@ -37,6 +37,10 @@
{{ authStore.error }} {{ authStore.error }}
</div> </div>
<div class="form-actions">
<router-link to="/forgot-password" class="forgot-link">Forgot password?</router-link>
</div>
<button type="submit" class="cta full" :disabled="authStore.loading"> <button type="submit" class="cta full" :disabled="authStore.loading">
{{ authStore.loading ? 'Signing in...' : 'Sign in' }} {{ authStore.loading ? 'Signing in...' : 'Sign in' }}
</button> </button>
@@ -167,6 +171,19 @@ const handleSubmit = async () => {
font-size: 0.9rem; font-size: 0.9rem;
} }
.form-actions {
text-align: right;
}
.forgot-link {
color: var(--muted);
font-size: 0.9rem;
}
.forgot-link:hover {
color: var(--accent);
}
.cta.full { .cta.full {
width: 100%; width: 100%;
padding: 14px; padding: 14px;

View File

@@ -0,0 +1,231 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Set new password</h1>
<p>Enter your new password below</p>
</div>
<form v-if="!success" @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="password">New Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="At least 8 characters"
minlength="8"
required
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Repeat your password"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="cta full" :disabled="loading || !isValid">
{{ loading ? 'Resetting...' : 'Reset password' }}
</button>
</form>
<div v-else class="success-message">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Password reset complete</h2>
<p>Your password has been updated. You can now sign in with your new password.</p>
<router-link to="/login" class="cta full">Sign in</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
const route = useRoute();
const password = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const error = ref('');
const success = ref(false);
const token = computed(() => route.query.token || '');
const isValid = computed(() => {
return password.value.length >= 8 && password.value === confirmPassword.value;
});
const handleSubmit = async () => {
if (!token.value) {
error.value = 'Invalid reset link. Please request a new one.';
return;
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match';
return;
}
loading.value = true;
error.value = '';
try {
await api.resetPassword(token.value, password.value);
success.value = true;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.success-message {
text-align: center;
}
.success-icon {
color: #22c55e;
margin-bottom: 16px;
}
.success-message h2 {
margin-bottom: 12px;
}
.success-message p {
color: var(--muted);
margin-bottom: 24px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="status-message loading">
<div class="spinner"></div>
<p>Verifying your email...</p>
</div>
<!-- Success state -->
<div v-else-if="success" class="status-message success">
<div class="status-icon success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Email verified!</h2>
<p>Your email has been verified successfully. You can now access all features.</p>
<router-link to="/dashboard" class="cta full">Go to Dashboard</router-link>
</div>
<!-- Error state -->
<div v-else-if="error" class="status-message error">
<div class="status-icon error-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<h2>Verification failed</h2>
<p>{{ error }}</p>
<div class="action-buttons">
<router-link to="/settings" class="cta full">Request new link</router-link>
<router-link to="/login" class="link-secondary">Back to login</router-link>
</div>
</div>
<!-- No token state -->
<div v-else class="status-message error">
<div class="status-icon error-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h2>Invalid link</h2>
<p>This verification link is invalid or missing. Please check your email for the correct link.</p>
<div class="action-buttons">
<router-link to="/settings" class="cta full">Request new link</router-link>
<router-link to="/login" class="link-secondary">Back to login</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
const route = useRoute();
const loading = ref(false);
const success = ref(false);
const error = ref('');
const token = computed(() => route.query.token || '');
const title = computed(() => {
if (loading.value) return 'Verifying email';
if (success.value) return 'Email verified';
if (error.value || !token.value) return 'Verification failed';
return 'Verify your email';
});
const subtitle = computed(() => {
if (loading.value) return 'Please wait while we verify your email address';
if (success.value) return 'Your account is now fully activated';
return 'There was a problem with your verification';
});
onMounted(async () => {
if (!token.value) {
return;
}
loading.value = true;
try {
await api.verifyEmail(token.value);
success.value = true;
} catch (err) {
error.value = err.message || 'Unable to verify your email. The link may be invalid or expired.';
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.status-message {
text-align: center;
}
.status-message.loading {
padding: 20px 0;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-message.loading p {
color: var(--muted);
}
.status-icon {
margin-bottom: 16px;
}
.success-icon {
color: #22c55e;
}
.error-icon {
color: #dc2626;
}
.status-message h2 {
font-size: 1.25rem;
margin-bottom: 12px;
}
.status-message p {
color: var(--muted);
margin-bottom: 24px;
line-height: 1.5;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
display: block;
text-align: center;
}
.link-secondary {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.link-secondary:hover {
color: var(--ink);
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,598 @@
<template>
<AppLayout>
<div class="billing">
<header class="page-header">
<h1>Billing & Plans</h1>
<p class="subtitle">Manage your subscription and view usage</p>
</header>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Current Plan Card -->
<section class="card current-plan">
<div class="plan-header">
<div>
<h2>Current Plan</h2>
<div class="plan-badge" :class="currentPlan.toLowerCase()">
{{ currentPlan }}
</div>
</div>
<button
v-if="subscription?.subscriptionId"
class="btn btn-secondary"
@click="openPortal"
:disabled="portalLoading"
>
{{ portalLoading ? 'Loading...' : 'Manage Subscription' }}
</button>
</div>
<div v-if="subscription?.cancelAtPeriodEnd" class="cancel-notice">
Your subscription will be canceled on {{ formatDate(subscription.currentPeriodEnd) }}.
You'll be downgraded to the Free plan after this date.
</div>
<div class="usage-section" v-if="usage">
<h3>Usage This Month</h3>
<div class="usage-grid">
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) + '%' }"
:class="{ warning: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Workspaces</span>
<span>{{ usage.workspaces }} / {{ formatLimit(usage.limits.maxWorkspaces) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.links, usage.limits.maxLinks) + '%' }"
:class="{ warning: getUsagePercent(usage.links, usage.limits.maxLinks) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Links</span>
<span>{{ usage.links }} / {{ formatLimit(usage.limits.maxLinks) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) + '%' }"
:class="{ warning: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) > 80 }"
></div>
</div>
<div class="usage-label">
<span>QR Codes</span>
<span>{{ usage.qrCodes }} / {{ formatLimit(usage.limits.maxQRCodes) }}</span>
</div>
</div>
<div class="usage-item">
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) + '%' }"
:class="{ warning: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) > 80 }"
></div>
</div>
<div class="usage-label">
<span>Events</span>
<span>{{ formatNumber(usage.eventsThisMonth) }} / {{ formatLimit(usage.limits.maxEventsPerMonth) }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- Plan Comparison -->
<section class="plans-section">
<h2>Available Plans</h2>
<div class="plans-grid">
<div class="plan-card" :class="{ current: currentPlan === 'Free' }">
<div class="plan-name">Free</div>
<div class="plan-price">
<span class="amount">$0</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>1 workspace</li>
<li>50 links</li>
<li>25 QR codes</li>
<li>10,000 events/month</li>
<li>Basic analytics</li>
</ul>
<button
v-if="currentPlan === 'Free'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
</div>
<div class="plan-card featured" :class="{ current: currentPlan === 'Pro' }">
<div class="popular-badge">Most Popular</div>
<div class="plan-name">Pro</div>
<div class="plan-price">
<span class="amount">$29</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>5 workspaces</li>
<li>5,000 links</li>
<li>1,000 QR codes</li>
<li>100,000 events/month</li>
<li>3 custom domains</li>
<li>Password protection</li>
<li>Advanced analytics</li>
</ul>
<button
v-if="currentPlan === 'Pro'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
<button
v-else-if="currentPlan === 'Free'"
class="btn btn-primary"
@click="upgrade('Pro')"
:disabled="upgrading"
>
{{ upgrading ? 'Loading...' : 'Upgrade to Pro' }}
</button>
<button
v-else
class="btn btn-secondary"
@click="openPortal"
>
Downgrade
</button>
</div>
<div class="plan-card" :class="{ current: currentPlan === 'Business' }">
<div class="plan-name">Business</div>
<div class="plan-price">
<span class="amount">$99</span>
<span class="period">/month</span>
</div>
<ul class="plan-features">
<li>Unlimited workspaces</li>
<li>Unlimited links</li>
<li>Unlimited QR codes</li>
<li>Unlimited events</li>
<li>Unlimited custom domains</li>
<li>Password protection</li>
<li>Priority support</li>
<li>API access</li>
</ul>
<button
v-if="currentPlan === 'Business'"
class="btn btn-secondary"
disabled
>
Current Plan
</button>
<button
v-else
class="btn btn-primary"
@click="upgrade('Business')"
:disabled="upgrading"
>
{{ upgrading ? 'Loading...' : 'Upgrade to Business' }}
</button>
</div>
</div>
</section>
</template>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const loading = ref(true);
const upgrading = ref(false);
const portalLoading = ref(false);
const error = ref('');
const usage = ref(null);
const subscription = ref(null);
const currentPlan = computed(() => usage.value?.plan || 'Free');
onMounted(async () => {
// Check for success from checkout
if (route.query.session_id) {
// Payment was successful, refresh data
setTimeout(() => loadData(), 1000);
} else {
loadData();
}
});
async function loadData() {
loading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
const [usageData, subData] = await Promise.all([
api.getUsage(workspaceId),
workspaceId ? api.getSubscription(workspaceId) : Promise.resolve(null),
]);
usage.value = usageData;
subscription.value = subData;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function upgrade(plan) {
upgrading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
if (!workspaceId) {
throw new Error('No workspace selected');
}
const successUrl = window.location.origin + '/billing';
const cancelUrl = window.location.origin + '/billing';
const { url } = await api.createCheckoutSession(
workspaceId,
plan,
successUrl,
cancelUrl
);
window.location.href = url;
} catch (err) {
error.value = err.message;
upgrading.value = false;
}
}
async function openPortal() {
portalLoading.value = true;
error.value = '';
try {
const returnUrl = window.location.origin + '/billing';
const { url } = await api.createPortalSession(returnUrl);
window.location.href = url;
} catch (err) {
error.value = err.message;
portalLoading.value = false;
}
}
function getUsagePercent(used, limit) {
if (limit === 2147483647 || limit === -1) return 0; // Unlimited
return Math.min(100, (used / limit) * 100);
}
function formatLimit(limit) {
if (limit === 2147483647 || limit === -1) return 'Unlimited';
return formatNumber(limit);
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<style scoped>
.billing {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.current-plan .plan-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.current-plan h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.plan-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.plan-badge.free {
background: #e5e7eb;
color: #374151;
}
.plan-badge.pro {
background: #dbeafe;
color: #1d4ed8;
}
.plan-badge.business {
background: #fef3c7;
color: #b45309;
}
.cancel-notice {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
color: #92400e;
}
.usage-section h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem;
color: #374151;
}
.usage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.usage-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.usage-bar {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background: #3b82f6;
border-radius: 4px;
transition: width 0.3s ease;
}
.usage-fill.warning {
background: #f59e0b;
}
.usage-label {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #6b7280;
}
.plans-section {
margin-top: 2rem;
}
.plans-section h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1.5rem;
}
.plans-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.plan-card {
background: white;
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
position: relative;
transition: border-color 0.2s, box-shadow 0.2s;
}
.plan-card:hover {
border-color: #d1d5db;
}
.plan-card.featured {
border-color: #3b82f6;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.15);
}
.plan-card.current {
background: #f9fafb;
}
.popular-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: #3b82f6;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
}
.plan-name {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.plan-price {
margin-bottom: 1.5rem;
}
.plan-price .amount {
font-size: 2rem;
font-weight: 700;
}
.plan-price .period {
color: #6b7280;
}
.plan-features {
list-style: none;
padding: 0;
margin: 0 0 1.5rem;
}
.plan-features li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: #374151;
}
.plan-features li::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2310b981'%3E%3Cpath fill-rule='evenodd' d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z' clip-rule='evenodd'/%3E%3C/svg%3E") no-repeat center;
}
.btn {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #dc2626;
margin-top: 1rem;
}
@media (max-width: 768px) {
.billing {
padding: 1rem;
}
.current-plan .plan-header {
flex-direction: column;
gap: 1rem;
}
.plans-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -145,6 +145,26 @@
<p>No referrer data yet</p> <p>No referrer data yet</p>
</div> </div>
</section> </section>
<section class="card">
<div class="card-header">
<h2>Top Countries</h2>
</div>
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
<div v-for="country in analytics.countryBreakdown.slice(0, 5)" :key="country.key" class="breakdown-item">
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
<span class="breakdown-label">
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
{{ getCountryName(country.key) }}
</span>
<span class="breakdown-value">{{ country.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No geographic data yet</p>
<p class="hint">Country detection requires a GeoIP database</p>
</div>
</section>
</div> </div>
</div> </div>
</AppLayout> </AppLayout>
@@ -189,6 +209,52 @@ const getPercentage = (value) => {
return Math.max((value / totalEvents.value) * 100, 5); return Math.max((value / totalEvents.value) * 100, 5);
}; };
// Country code to flag emoji converter
const getCountryFlag = (countryCode) => {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// Country code to name mapping (common codes)
const countryNames = {
US: 'United States',
GB: 'United Kingdom',
CA: 'Canada',
AU: 'Australia',
DE: 'Germany',
FR: 'France',
JP: 'Japan',
CN: 'China',
IN: 'India',
BR: 'Brazil',
MX: 'Mexico',
ES: 'Spain',
IT: 'Italy',
NL: 'Netherlands',
SE: 'Sweden',
NO: 'Norway',
DK: 'Denmark',
FI: 'Finland',
PL: 'Poland',
RU: 'Russia',
KR: 'South Korea',
SG: 'Singapore',
NZ: 'New Zealand',
IE: 'Ireland',
CH: 'Switzerland',
AT: 'Austria',
BE: 'Belgium',
PT: 'Portugal',
};
const getCountryName = (countryCode) => {
return countryNames[countryCode] || countryCode;
};
onMounted(async () => { onMounted(async () => {
await workspaceStore.fetchLinks(); await workspaceStore.fetchLinks();
await workspaceStore.fetchAnalytics(period.value); await workspaceStore.fetchAnalytics(period.value);
@@ -417,10 +483,24 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
color: var(--muted); color: var(--muted);
} }
.empty-state .hint {
font-size: 0.85rem;
margin-top: 8px;
}
.empty-state .cta { .empty-state .cta {
margin-top: 16px; margin-top: 16px;
} }
.country-bar {
background: rgba(59, 130, 246, 0.15);
}
.country-flag {
margin-right: 8px;
font-size: 1.1em;
}
.cta.small { .cta.small {
padding: 10px 16px; padding: 10px 16px;
font-size: 0.9rem; font-size: 0.9rem;

View File

@@ -0,0 +1,761 @@
<template>
<AppLayout>
<div class="domains">
<header class="page-header">
<div>
<h1>Custom Domains</h1>
<p class="subtitle">Use your own domain for branded short links</p>
</div>
<button class="btn btn-primary" @click="showAddModal = true" :disabled="!canAddDomain">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Domain
</button>
</header>
<div v-if="!canAddDomain" class="upgrade-banner">
<div class="banner-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<div>
<strong>Custom domains require a Pro or Business plan.</strong>
<p>Upgrade to use your own branded domains for short links.</p>
</div>
</div>
<router-link to="/billing" class="btn btn-primary">Upgrade</router-link>
</div>
<div v-if="loading" class="loading">Loading domains...</div>
<div v-else-if="domains.length === 0 && canAddDomain" class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<h3>No custom domains yet</h3>
<p>Add your own domain to create branded short links.</p>
<button class="btn btn-primary" @click="showAddModal = true">Add Domain</button>
</div>
<div v-else-if="domains.length > 0" class="domains-list">
<div v-for="domain in domains" :key="domain.id" class="domain-card">
<div class="domain-info">
<div class="domain-status" :class="domain.status.toLowerCase()">
{{ domain.status }}
</div>
<h3 class="domain-name">{{ domain.hostname }}</h3>
<p class="domain-date">Added {{ formatDate(domain.createdAt) }}</p>
</div>
<div class="domain-actions">
<template v-if="domain.status === 'Pending'">
<button class="btn btn-secondary" @click="showVerifyInstructions(domain)">
Verify Domain
</button>
</template>
<button class="icon-btn danger" @click="confirmDelete(domain)" title="Delete">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Add Domain Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<div class="modal-header">
<h2>Add Custom Domain</h2>
<button class="close-btn" @click="showAddModal = false">&times;</button>
</div>
<form @submit.prevent="addDomain">
<div class="form-group">
<label for="hostname">Domain</label>
<input
id="hostname"
v-model="newDomain"
type="text"
placeholder="links.yourdomain.com"
required
autofocus
/>
<p class="form-hint">Enter the subdomain or domain you want to use for short links.</p>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="adding">
{{ adding ? 'Adding...' : 'Add Domain' }}
</button>
</div>
</form>
</div>
</div>
<!-- Verification Instructions Modal -->
<div v-if="showVerifyModal" class="modal-overlay" @click.self="showVerifyModal = false">
<div class="modal modal-lg">
<div class="modal-header">
<h2>Verify {{ verifyingDomain?.hostname }}</h2>
<button class="close-btn" @click="showVerifyModal = false">&times;</button>
</div>
<div class="verify-content">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>Add DNS TXT Record</h4>
<p>Add the following TXT record to your domain's DNS settings:</p>
<div class="dns-record">
<div class="record-row">
<span class="record-label">Type:</span>
<span class="record-value">TXT</span>
</div>
<div class="record-row">
<span class="record-label">Host/Name:</span>
<span class="record-value">_trakqr-verification</span>
</div>
<div class="record-row">
<span class="record-label">Value:</span>
<div class="record-value token">
{{ verifyingDomain?.verificationToken }}
<button class="copy-btn" @click="copyToken" title="Copy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>Add CNAME Record</h4>
<p>Point your domain to our servers:</p>
<div class="dns-record">
<div class="record-row">
<span class="record-label">Type:</span>
<span class="record-value">CNAME</span>
</div>
<div class="record-row">
<span class="record-label">Host/Name:</span>
<span class="record-value">{{ verifyingDomain?.hostname }}</span>
</div>
<div class="record-row">
<span class="record-label">Value:</span>
<span class="record-value">cname.trakqr.com</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>Verify Ownership</h4>
<p>DNS changes can take up to 48 hours to propagate. Once ready, click verify.</p>
</div>
</div>
<div v-if="verifyError" class="error-message">{{ verifyError }}</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showVerifyModal = false">Close</button>
<button class="btn btn-primary" @click="verifyDomain" :disabled="verifying">
{{ verifying ? 'Verifying...' : 'Verify Domain' }}
</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Domain</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<p class="delete-warning">
Are you sure you want to delete <strong>{{ domainToDelete?.hostname }}</strong>?
Links using this domain will stop working.
</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteDomain" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const workspaceStore = useWorkspaceStore();
const domains = computed(() => workspaceStore.domains);
const loading = ref(true);
const adding = ref(false);
const verifying = ref(false);
const deleting = ref(false);
const error = ref('');
const verifyError = ref('');
const showAddModal = ref(false);
const showVerifyModal = ref(false);
const showDeleteModal = ref(false);
const newDomain = ref('');
const verifyingDomain = ref(null);
const domainToDelete = ref(null);
const canAddDomain = computed(() => workspaceStore.canUseCustomDomains);
onMounted(async () => {
await loadDomains();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadDomains();
}
});
async function loadDomains() {
loading.value = true;
try {
await workspaceStore.fetchDomains();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function addDomain() {
adding.value = true;
error.value = '';
try {
const domain = await workspaceStore.addDomain(newDomain.value);
showAddModal.value = false;
newDomain.value = '';
// Show verification instructions for the new domain
verifyingDomain.value = domain;
showVerifyModal.value = true;
} catch (err) {
error.value = err.message;
} finally {
adding.value = false;
}
}
function showVerifyInstructions(domain) {
verifyingDomain.value = domain;
verifyError.value = '';
showVerifyModal.value = true;
}
async function verifyDomain() {
verifying.value = true;
verifyError.value = '';
try {
await workspaceStore.verifyDomain(verifyingDomain.value.id);
showVerifyModal.value = false;
verifyingDomain.value = null;
} catch (err) {
verifyError.value = err.message;
} finally {
verifying.value = false;
}
}
function confirmDelete(domain) {
domainToDelete.value = domain;
showDeleteModal.value = true;
}
async function deleteDomain() {
deleting.value = true;
try {
await workspaceStore.deleteDomain(domainToDelete.value.id);
showDeleteModal.value = false;
domainToDelete.value = null;
} catch (err) {
error.value = err.message;
} finally {
deleting.value = false;
}
}
function copyToken() {
navigator.clipboard.writeText(verifyingDomain.value?.verificationToken);
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<style scoped>
.domains {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.upgrade-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 12px;
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.banner-content {
display: flex;
align-items: flex-start;
gap: 1rem;
color: #92400e;
}
.banner-content svg {
flex-shrink: 0;
margin-top: 2px;
}
.banner-content strong {
display: block;
margin-bottom: 0.25rem;
}
.banner-content p {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
color: #d1d5db;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.empty-state p {
color: #6b7280;
margin: 0 0 1.5rem;
}
.domains-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.domain-card {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
border-radius: 12px;
padding: 1.25rem 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.domain-status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.domain-status.pending {
background: #fef3c7;
color: #92400e;
}
.domain-status.verified {
background: #d1fae5;
color: #065f46;
}
.domain-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.domain-date {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.domain-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
text-decoration: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.icon-btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s;
}
.icon-btn:hover {
background: #f3f4f6;
}
.icon-btn.danger:hover {
background: #fee2e2;
color: #dc2626;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 400px;
}
.modal-lg {
max-width: 600px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
font-size: 0.75rem;
color: #6b7280;
margin: 0.5rem 0 0;
}
.verify-content {
padding: 1.5rem;
}
.step {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.step:last-of-type {
margin-bottom: 0;
}
.step-number {
width: 28px;
height: 28px;
background: #3b82f6;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 600;
flex-shrink: 0;
}
.step-content h4 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.step-content p {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 1rem;
}
.dns-record {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
}
.record-row {
display: flex;
margin-bottom: 0.5rem;
}
.record-row:last-child {
margin-bottom: 0;
}
.record-label {
width: 100px;
font-size: 0.75rem;
color: #6b7280;
flex-shrink: 0;
}
.record-value {
font-size: 0.875rem;
font-family: monospace;
word-break: break-all;
}
.record-value.token {
display: flex;
align-items: center;
gap: 0.5rem;
}
.copy-btn {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: #6b7280;
border-radius: 4px;
}
.copy-btn:hover {
background: #e5e7eb;
color: #374151;
}
.delete-warning {
padding: 1.5rem;
color: #374151;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin: 1rem 0 0;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.upgrade-banner {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.banner-content {
flex-direction: column;
align-items: center;
}
.domain-card {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}
</style>

View File

@@ -97,6 +97,23 @@
<p>No referrer data yet</p> <p>No referrer data yet</p>
</div> </div>
</section> </section>
<section class="card">
<h2>Countries</h2>
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
<div v-for="country in analytics.countryBreakdown" :key="country.key" class="breakdown-item">
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
<span class="breakdown-label">
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
{{ getCountryName(country.key) }}
</span>
<span class="breakdown-value">{{ country.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No geographic data yet</p>
</div>
</section>
</div> </div>
<section class="card link-info-card"> <section class="card link-info-card">
@@ -211,6 +228,28 @@ const copyToClipboard = async () => {
} }
}; };
// Country code to flag emoji converter
const getCountryFlag = (countryCode) => {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// Country code to name mapping
const countryNames = {
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands',
SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland',
RU: 'Russia', KR: 'South Korea', SG: 'Singapore', NZ: 'New Zealand',
IE: 'Ireland', CH: 'Switzerland', AT: 'Austria', BE: 'Belgium', PT: 'Portugal',
};
const getCountryName = (countryCode) => countryNames[countryCode] || countryCode;
onMounted(async () => { onMounted(async () => {
await fetchData(); await fetchData();
}); });
@@ -440,6 +479,15 @@ onMounted(async () => {
color: var(--muted); color: var(--muted);
} }
.country-bar {
background: rgba(59, 130, 246, 0.15);
}
.country-flag {
margin-right: 8px;
font-size: 1.1em;
}
.loading { .loading {
text-align: center; text-align: center;
padding: 60px; padding: 60px;

View File

@@ -6,6 +6,27 @@
<h1>Links</h1> <h1>Links</h1>
<p class="subtitle">Manage your short links</p> <p class="subtitle">Manage your short links</p>
</div> </div>
<div class="view-toggle">
<button :class="{ active: !showDeleted }" @click="showDeleted = false">
Active
</button>
<button :class="{ active: showDeleted }" @click="toggleDeleted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Trash
</button>
</div>
<div class="header-actions">
<button @click="showBulkModal = true" class="ghost">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Bulk Import
</button>
<button @click="showCreateModal = true" class="cta"> <button @click="showCreateModal = true" class="cta">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/> <line x1="12" y1="5" x2="12" y2="19"/>
@@ -13,6 +34,7 @@
</svg> </svg>
Create Link Create Link
</button> </button>
</div>
</header> </header>
<div class="links-list" v-if="workspaceStore.links.length"> <div class="links-list" v-if="workspaceStore.links.length">
@@ -125,6 +147,60 @@
<p class="hint">Leave empty for auto-generated slug</p> <p class="hint">Leave empty for auto-generated slug</p>
</div> </div>
<!-- UTM Builder -->
<div class="utm-section">
<button type="button" class="utm-toggle" @click="showUtmBuilder = !showUtmBuilder">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
{{ showUtmBuilder ? 'Hide' : 'Add' }} UTM Parameters
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ rotated: showUtmBuilder }">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
<div v-if="showUtmBuilder" class="utm-fields">
<div class="utm-presets">
<span class="utm-preset-label">Presets:</span>
<button type="button" @click="applyUtmPreset('google')" class="utm-preset-btn">Google Ads</button>
<button type="button" @click="applyUtmPreset('facebook')" class="utm-preset-btn">Facebook</button>
<button type="button" @click="applyUtmPreset('email')" class="utm-preset-btn">Email</button>
<button type="button" @click="applyUtmPreset('social')" class="utm-preset-btn">Social</button>
</div>
<div class="form-row">
<div class="form-group">
<label for="utm_source">Source</label>
<input id="utm_source" v-model="utmParams.source" type="text" placeholder="google, facebook, newsletter" />
</div>
<div class="form-group">
<label for="utm_medium">Medium</label>
<input id="utm_medium" v-model="utmParams.medium" type="text" placeholder="cpc, social, email" />
</div>
</div>
<div class="form-group">
<label for="utm_campaign">Campaign</label>
<input id="utm_campaign" v-model="utmParams.campaign" type="text" placeholder="summer_sale, product_launch" />
</div>
<div class="form-row">
<div class="form-group">
<label for="utm_term">Term (optional)</label>
<input id="utm_term" v-model="utmParams.term" type="text" placeholder="keywords" />
</div>
<div class="form-group">
<label for="utm_content">Content (optional)</label>
<input id="utm_content" v-model="utmParams.content" type="text" placeholder="banner, textlink" />
</div>
</div>
<p v-if="utmPreview" class="utm-preview">
<strong>Preview:</strong> {{ utmPreview }}
</p>
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="expiresAt">Expires (optional)</label> <label for="expiresAt">Expires (optional)</label>
@@ -171,24 +247,91 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Bulk Import Modal -->
<div v-if="showBulkModal" class="modal-overlay" @click.self="closeBulkModal">
<div class="modal modal-lg">
<div class="modal-header">
<h2>Bulk Import Links</h2>
<button @click="closeBulkModal" class="close-btn">&times;</button>
</div>
<div class="bulk-content">
<p class="bulk-instructions">Paste URLs below, one per line. You can optionally add a title after the URL separated by a comma.</p>
<p class="bulk-example">Example: https://example.com, My Link Title</p>
<textarea
v-model="bulkUrls"
placeholder="https://example.com&#10;https://another-site.com, My Page&#10;https://third-url.com"
rows="10"
class="bulk-textarea"
></textarea>
<div class="bulk-stats">
<span>{{ parsedBulkLinks.length }} URLs detected</span>
</div>
<div v-if="bulkError" class="error-message">{{ bulkError }}</div>
<div v-if="bulkResults" class="bulk-results">
<div v-if="bulkResults.created.length" class="bulk-success">
<strong>{{ bulkResults.created.length }}</strong> links created successfully
</div>
<div v-if="bulkResults.errors.length" class="bulk-errors">
<strong>{{ bulkResults.errors.length }}</strong> errors:
<ul>
<li v-for="(err, i) in bulkResults.errors.slice(0, 5)" :key="i">
{{ err.url }}: {{ err.error }}
</li>
<li v-if="bulkResults.errors.length > 5">
...and {{ bulkResults.errors.length - 5 }} more
</li>
</ul>
</div>
</div>
</div>
<div class="modal-actions">
<button @click="closeBulkModal" class="ghost">{{ bulkResults ? 'Close' : 'Cancel' }}</button>
<button
v-if="!bulkResults"
@click="importBulkLinks"
class="cta"
:disabled="bulkImporting || parsedBulkLinks.length === 0"
>
{{ bulkImporting ? 'Importing...' : `Import ${parsedBulkLinks.length} Links` }}
</button>
</div>
</div>
</div>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import AppLayout from '../../components/layout/AppLayout.vue'; import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace'; import { useWorkspaceStore } from '../../stores/workspace';
import { api } from '../../api/client';
const workspaceStore = useWorkspaceStore(); const workspaceStore = useWorkspaceStore();
const showCreateModal = ref(false); const showCreateModal = ref(false);
const showDeleteModal = ref(false); const showDeleteModal = ref(false);
const showBulkModal = ref(false);
const showUtmBuilder = ref(false);
const showDeleted = ref(false);
const editingLink = ref(null); const editingLink = ref(null);
const deletingLink = ref(null); const deletingLink = ref(null);
const saving = ref(false); const saving = ref(false);
const formError = ref(''); const formError = ref('');
// Bulk import state
const bulkUrls = ref('');
const bulkImporting = ref(false);
const bulkError = ref('');
const bulkResults = ref(null);
const formData = ref({ const formData = ref({
destinationUrl: '', destinationUrl: '',
title: '', title: '',
@@ -197,6 +340,44 @@ const formData = ref({
password: '', password: '',
}); });
const utmParams = ref({
source: '',
medium: '',
campaign: '',
term: '',
content: '',
});
const utmPresets = {
google: { source: 'google', medium: 'cpc', campaign: '' },
facebook: { source: 'facebook', medium: 'social', campaign: '' },
email: { source: 'newsletter', medium: 'email', campaign: '' },
social: { source: 'twitter', medium: 'social', campaign: '' },
};
const applyUtmPreset = (preset) => {
const p = utmPresets[preset];
if (p) {
utmParams.value = { ...utmParams.value, ...p };
}
};
const utmPreview = computed(() => {
const params = [];
if (utmParams.value.source) params.push(`utm_source=${utmParams.value.source}`);
if (utmParams.value.medium) params.push(`utm_medium=${utmParams.value.medium}`);
if (utmParams.value.campaign) params.push(`utm_campaign=${utmParams.value.campaign}`);
if (utmParams.value.term) params.push(`utm_term=${utmParams.value.term}`);
if (utmParams.value.content) params.push(`utm_content=${utmParams.value.content}`);
return params.length ? '?' + params.join('&') : '';
});
const buildUrlWithUtm = (baseUrl) => {
if (!utmPreview.value) return baseUrl;
const separator = baseUrl.includes('?') ? '&' : '?';
return baseUrl + separator + utmPreview.value.substring(1);
};
const resetForm = () => { const resetForm = () => {
formData.value = { formData.value = {
destinationUrl: '', destinationUrl: '',
@@ -205,6 +386,14 @@ const resetForm = () => {
expiresAt: '', expiresAt: '',
password: '', password: '',
}; };
utmParams.value = {
source: '',
medium: '',
campaign: '',
term: '',
content: '',
};
showUtmBuilder.value = false;
formError.value = ''; formError.value = '';
editingLink.value = null; editingLink.value = null;
}; };
@@ -231,8 +420,11 @@ const saveLink = async () => {
formError.value = ''; formError.value = '';
try { try {
// Build destination URL with UTM parameters
const finalUrl = buildUrlWithUtm(formData.value.destinationUrl);
const data = { const data = {
destinationUrl: formData.value.destinationUrl, destinationUrl: finalUrl,
title: formData.value.title || null, title: formData.value.title || null,
expiresAt: formData.value.expiresAt ? new Date(formData.value.expiresAt).toISOString() : null, expiresAt: formData.value.expiresAt ? new Date(formData.value.expiresAt).toISOString() : null,
password: formData.value.password || null, password: formData.value.password || null,
@@ -287,15 +479,84 @@ const copyToClipboard = async (text) => {
} }
}; };
// Bulk import functions
const parsedBulkLinks = computed(() => {
if (!bulkUrls.value.trim()) return [];
return bulkUrls.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => {
const parts = line.split(',');
const url = parts[0].trim();
const title = parts.length > 1 ? parts.slice(1).join(',').trim() : null;
return { destinationUrl: url, title };
})
.filter(item => {
try {
new URL(item.destinationUrl);
return true;
} catch {
return false;
}
});
});
const closeBulkModal = () => {
showBulkModal.value = false;
bulkUrls.value = '';
bulkError.value = '';
bulkResults.value = null;
};
const importBulkLinks = async () => {
if (parsedBulkLinks.value.length === 0) return;
bulkImporting.value = true;
bulkError.value = '';
try {
const workspaceId = workspaceStore.currentWorkspaceId;
const result = await api.bulkCreateLinks(workspaceId, parsedBulkLinks.value);
bulkResults.value = result;
// Refresh links list
await workspaceStore.fetchLinks();
} catch (err) {
bulkError.value = err.message;
} finally {
bulkImporting.value = false;
}
};
const toggleDeleted = async () => {
showDeleted.value = true;
await workspaceStore.fetchLinks({ includeDeleted: true });
};
const restoreLink = async (link) => {
try {
await api.restoreLink(workspaceStore.currentWorkspaceId, link.id);
await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
} catch (err) {
console.error('Failed to restore link:', err);
}
};
onMounted(async () => { onMounted(async () => {
await workspaceStore.fetchLinks(); await workspaceStore.fetchLinks();
}); });
watch(() => workspaceStore.currentWorkspaceId, async () => { watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) { if (workspaceStore.currentWorkspaceId) {
await workspaceStore.fetchLinks(); await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
} }
}); });
watch(showDeleted, async (value) => {
await workspaceStore.fetchLinks({ includeDeleted: value });
});
</script> </script>
<style scoped> <style scoped>
@@ -581,6 +842,176 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
background: #dc2626; background: #dc2626;
} }
/* UTM Builder styles */
.utm-section {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.utm-toggle {
width: 100%;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: none;
font-size: 0.9rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
transition: all 0.15s ease;
}
.utm-toggle:hover {
background: var(--line);
color: var(--ink);
}
.utm-toggle svg:last-child {
margin-left: auto;
transition: transform 0.2s ease;
}
.utm-toggle svg.rotated {
transform: rotate(180deg);
}
.utm-fields {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
border-top: 1px solid var(--line);
}
.utm-presets {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.utm-preset-label {
font-size: 0.85rem;
color: var(--muted);
}
.utm-preset-btn {
padding: 6px 12px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
color: var(--ink);
cursor: pointer;
transition: all 0.15s ease;
}
.utm-preset-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.utm-preview {
padding: 12px;
background: var(--bg);
border-radius: 8px;
font-size: 0.85rem;
color: var(--muted);
word-break: break-all;
}
.utm-preview strong {
color: var(--ink);
}
/* Bulk import styles */
.header-actions {
display: flex;
gap: 12px;
}
.modal-lg {
max-width: 600px;
}
.bulk-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.bulk-instructions {
color: var(--ink);
font-size: 0.95rem;
}
.bulk-example {
color: var(--muted);
font-size: 0.85rem;
font-style: italic;
}
.bulk-textarea {
width: 100%;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 0.9rem;
font-family: monospace;
resize: vertical;
line-height: 1.6;
}
.bulk-textarea:focus {
outline: none;
border-color: var(--accent);
}
.bulk-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.875rem;
color: var(--muted);
}
.bulk-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.bulk-success {
padding: 12px 16px;
background: #dcfce7;
border-radius: 10px;
color: #16a34a;
font-size: 0.9rem;
}
.bulk-errors {
padding: 12px 16px;
background: #fef2f2;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.bulk-errors ul {
margin: 8px 0 0;
padding-left: 20px;
}
.bulk-errors li {
margin-top: 4px;
font-size: 0.85rem;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.link-card { .link-card {
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,566 @@
<template>
<AppLayout>
<div class="projects">
<header class="page-header">
<div>
<h1>Projects</h1>
<p class="subtitle">Organize your links and QR codes into projects</p>
</div>
<button class="btn btn-primary" @click="showCreateModal = true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Project
</button>
</header>
<div v-if="loading" class="loading">Loading projects...</div>
<div v-else-if="projects.length === 0" class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<h3>No projects yet</h3>
<p>Create your first project to organize your links and QR codes.</p>
<button class="btn btn-primary" @click="showCreateModal = true">Create Project</button>
</div>
<div v-else class="projects-grid">
<div v-for="project in projects" :key="project.id" class="project-card">
<div class="project-header">
<div class="project-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="project-actions">
<button class="icon-btn" @click="editProject(project)" title="Edit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="icon-btn danger" @click="confirmDelete(project)" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
<h3 class="project-name">{{ project.name }}</h3>
<p class="project-description">{{ project.description || 'No description' }}</p>
<div class="project-meta">
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{{ project.linkCount || 0 }} links
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<rect x="7" y="7" width="3" height="3"/>
<rect x="14" y="7" width="3" height="3"/>
<rect x="7" y="14" width="3" height="3"/>
<rect x="14" y="14" width="3" height="3"/>
</svg>
{{ project.qrCodeCount || 0 }} QR codes
</span>
<span class="meta-item">
Created {{ formatDate(project.createdAt) }}
</span>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div v-if="showCreateModal || showEditModal" class="modal-overlay" @click.self="closeModals">
<div class="modal">
<div class="modal-header">
<h2>{{ showEditModal ? 'Edit Project' : 'New Project' }}</h2>
<button class="close-btn" @click="closeModals">&times;</button>
</div>
<form @submit.prevent="showEditModal ? updateProject() : createProject()">
<div class="form-group">
<label for="name">Project Name</label>
<input
id="name"
v-model="form.name"
type="text"
placeholder="e.g., Marketing Campaign Q1"
required
autofocus
/>
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea
id="description"
v-model="form.description"
placeholder="Describe what this project is for..."
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="closeModals">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (showEditModal ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Project</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<p class="delete-warning">
Are you sure you want to delete <strong>{{ projectToDelete?.name }}</strong>?
This action cannot be undone.
</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteProject" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const workspaceStore = useWorkspaceStore();
const projects = computed(() => workspaceStore.projects);
const loading = ref(true);
const saving = ref(false);
const deleting = ref(false);
const error = ref('');
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const form = ref({ name: '', description: '' });
const editingProject = ref(null);
const projectToDelete = ref(null);
onMounted(async () => {
await loadProjects();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadProjects();
}
});
async function loadProjects() {
loading.value = true;
try {
await workspaceStore.fetchProjects();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function createProject() {
saving.value = true;
error.value = '';
try {
await workspaceStore.createProject(form.value.name, form.value.description);
closeModals();
} catch (err) {
error.value = err.message;
} finally {
saving.value = false;
}
}
function editProject(project) {
editingProject.value = project;
form.value = { name: project.name, description: project.description || '' };
showEditModal.value = true;
}
async function updateProject() {
saving.value = true;
error.value = '';
try {
await workspaceStore.updateProject(editingProject.value.id, {
name: form.value.name,
description: form.value.description
});
closeModals();
} catch (err) {
error.value = err.message;
} finally {
saving.value = false;
}
}
function confirmDelete(project) {
projectToDelete.value = project;
showDeleteModal.value = true;
}
async function deleteProject() {
deleting.value = true;
try {
await workspaceStore.deleteProject(projectToDelete.value.id);
showDeleteModal.value = false;
projectToDelete.value = null;
} catch (err) {
error.value = err.message;
} finally {
deleting.value = false;
}
}
function closeModals() {
showCreateModal.value = false;
showEditModal.value = false;
form.value = { name: '', description: '' };
editingProject.value = null;
error.value = '';
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
</script>
<style scoped>
.projects {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
color: #d1d5db;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.empty-state p {
color: #6b7280;
margin: 0 0 1.5rem;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.project-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.project-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.project-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.project-icon {
width: 40px;
height: 40px;
background: #f3f4f6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
}
.project-actions {
display: flex;
gap: 0.5rem;
}
.icon-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s;
}
.icon-btn:hover {
background: #f3f4f6;
color: #374151;
}
.icon-btn.danger:hover {
background: #fee2e2;
color: #dc2626;
}
.project-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.project-description {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1rem;
line-height: 1.5;
}
.project-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 400px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.delete-warning {
padding: 1.5rem;
color: #374151;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -142,6 +142,84 @@
/> />
<span class="range-value">{{ formData.style.quietZone }} modules</span> <span class="range-value">{{ formData.style.quietZone }} modules</span>
</div> </div>
<div class="form-group">
<label>Module Shape</label>
<div class="shape-selector">
<button
v-for="shape in moduleShapes"
:key="shape.value"
:class="{ active: formData.style.moduleShape === shape.value }"
@click="formData.style.moduleShape = shape.value"
class="shape-btn"
>
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
{{ shape.label }}
</button>
</div>
</div>
<div class="form-group">
<label>Eye Shape</label>
<div class="shape-selector">
<button
v-for="shape in eyeShapes"
:key="shape.value"
:class="{ active: formData.style.eyeShape === shape.value }"
@click="formData.style.eyeShape = shape.value"
class="shape-btn"
>
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
{{ shape.label }}
</button>
</div>
<p class="hint">Eyes are the large corner patterns for scanner detection</p>
</div>
</div>
<div class="settings-card">
<h2>Logo</h2>
<div class="logo-section">
<div class="current-logo" v-if="formData.logoAssetId">
<img :src="getLogoUrl(formData.logoAssetId)" alt="Current logo" class="logo-preview" />
<button @click="removeLogo" class="remove-logo-btn" title="Remove logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="logo-upload" v-else>
<label class="upload-btn">
<input
type="file"
@change="handleLogoUpload"
accept="image/png,image/jpeg,image/svg+xml"
hidden
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload Logo
</label>
</div>
<div v-if="assets.length > 0 && !formData.logoAssetId" class="existing-logos">
<p class="hint">Or select from existing:</p>
<div class="logos-grid">
<button
v-for="asset in assets"
:key="asset.id"
@click="selectLogo(asset)"
class="logo-option"
>
<img :src="asset.url" :alt="asset.filename" />
</button>
</div>
</div>
<p class="hint" v-if="formData.logoAssetId">Use high error correction (H) for better logo visibility</p>
</div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
@@ -188,42 +266,96 @@ const saving = ref(false);
const error = ref(''); const error = ref('');
const previewUrl = ref(''); const previewUrl = ref('');
const previewTimeout = ref(null); const previewTimeout = ref(null);
const uploadingLogo = ref(false);
const assets = computed(() => workspaceStore.assets.filter(a => a.type === 'Logo'));
const formData = ref({ const formData = ref({
name: '', name: '',
linkId: '', linkId: '',
logoAssetId: null,
style: { style: {
foregroundColor: '#000000', foregroundColor: '#000000',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
errorCorrectionLevel: 'M', errorCorrectionLevel: 'M',
quietZone: 4, quietZone: 4,
moduleShape: 'Square',
eyeShape: 'Square',
}, },
}); });
const moduleShapes = [
{ value: 'Square', label: 'Square' },
{ value: 'Rounded', label: 'Rounded' },
{ value: 'Dots', label: 'Dots' },
];
const eyeShapes = [
{ value: 'Square', label: 'Square' },
{ value: 'Rounded', label: 'Rounded' },
{ value: 'Circle', label: 'Circle' },
];
const getLogoUrl = (assetId) => {
const asset = workspaceStore.assets.find(a => a.id === assetId);
return asset?.url || '';
};
const handleLogoUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
uploadingLogo.value = true;
try {
const asset = await workspaceStore.uploadAsset(file);
formData.value.logoAssetId = asset.id;
// If adding a logo, suggest high error correction
if (formData.value.style.errorCorrectionLevel !== 'H') {
formData.value.style.errorCorrectionLevel = 'H';
}
} catch (err) {
error.value = 'Failed to upload logo: ' + err.message;
} finally {
uploadingLogo.value = false;
event.target.value = '';
}
};
const selectLogo = (asset) => {
formData.value.logoAssetId = asset.id;
// If adding a logo, suggest high error correction
if (formData.value.style.errorCorrectionLevel !== 'H') {
formData.value.style.errorCorrectionLevel = 'H';
}
};
const removeLogo = () => {
formData.value.logoAssetId = null;
};
const presets = [ const presets = [
{ {
name: 'Classic', name: 'Classic',
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4 } style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
}, },
{ {
name: 'Dark', name: 'Modern',
style: { foregroundColor: '#ffffff', backgroundColor: '#1a1a1a', errorCorrectionLevel: 'M', quietZone: 4 } style: { foregroundColor: '#1a1a1a', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
},
{
name: 'Dots',
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'H', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
}, },
{ {
name: 'Ocean', name: 'Ocean',
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4 } style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
}, },
{ {
name: 'Forest', name: 'Forest',
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4 } style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
},
{
name: 'Sunset',
style: { foregroundColor: '#c2410c', backgroundColor: '#fff7ed', errorCorrectionLevel: 'M', quietZone: 4 }
}, },
{ {
name: 'Purple', name: 'Purple',
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4 } style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
}, },
]; ];
@@ -258,11 +390,16 @@ const save = async () => {
try { try {
const data = { const data = {
name: formData.value.name, name: formData.value.name,
linkId: formData.value.linkId, shortLinkId: formData.value.linkId,
logoAssetId: formData.value.logoAssetId,
style: formData.value.style, style: formData.value.style,
}; };
if (isEditing.value) { if (isEditing.value) {
// For updates, handle logo removal
if (!formData.value.logoAssetId) {
data.removeLogo = true;
}
await workspaceStore.updateQRCode(route.params.id, data); await workspaceStore.updateQRCode(route.params.id, data);
} else { } else {
await workspaceStore.createQRCode(data); await workspaceStore.createQRCode(data);
@@ -291,15 +428,19 @@ const loadExisting = async () => {
try { try {
const qr = await api.getQRCode(workspaceStore.currentWorkspaceId, route.params.id); const qr = await api.getQRCode(workspaceStore.currentWorkspaceId, route.params.id);
formData.value = { const defaultStyle = {
name: qr.name,
linkId: qr.linkId,
style: qr.style || {
foregroundColor: '#000000', foregroundColor: '#000000',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
errorCorrectionLevel: 'M', errorCorrectionLevel: 'M',
quietZone: 4, quietZone: 4,
}, moduleShape: 'Square',
eyeShape: 'Square',
};
formData.value = {
name: qr.name,
linkId: qr.shortLinkId || qr.linkId,
logoAssetId: qr.logoAssetId || null,
style: { ...defaultStyle, ...qr.style },
}; };
await fetchPreview(); await fetchPreview();
} catch (err) { } catch (err) {
@@ -312,8 +453,16 @@ watch(() => formData.value.style, () => {
previewTimeout.value = setTimeout(fetchPreview, 500); previewTimeout.value = setTimeout(fetchPreview, 500);
}, { deep: true }); }, { deep: true });
watch(() => formData.value.logoAssetId, () => {
if (previewTimeout.value) clearTimeout(previewTimeout.value);
previewTimeout.value = setTimeout(fetchPreview, 500);
});
onMounted(async () => { onMounted(async () => {
await workspaceStore.fetchLinks(); await Promise.all([
workspaceStore.fetchLinks(),
workspaceStore.fetchAssets(),
]);
if (isEditing.value) { if (isEditing.value) {
await loadExisting(); await loadExisting();
} }
@@ -519,6 +668,150 @@ onMounted(async () => {
font-weight: 500; font-weight: 500;
} }
.logo-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.current-logo {
position: relative;
display: inline-block;
width: 80px;
height: 80px;
}
.logo-preview {
width: 80px;
height: 80px;
object-fit: contain;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--bg);
}
.remove-logo-btn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #dc2626;
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.remove-logo-btn:hover {
background: #b91c1c;
}
.logo-upload {
display: flex;
gap: 12px;
}
.upload-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--bg);
border: 2px dashed var(--line);
border-radius: 12px;
cursor: pointer;
font-weight: 500;
transition: all 0.15s ease;
}
.upload-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.existing-logos {
margin-top: 8px;
}
.logos-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.logo-option {
width: 48px;
height: 48px;
padding: 4px;
border: 2px solid var(--line);
border-radius: 8px;
background: var(--surface);
cursor: pointer;
transition: all 0.15s ease;
}
.logo-option:hover {
border-color: var(--accent);
}
.logo-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.shape-selector {
display: flex;
gap: 8px;
}
.shape-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 8px;
border: 2px solid var(--line);
border-radius: 12px;
background: var(--surface);
cursor: pointer;
transition: all 0.15s ease;
}
.shape-btn:hover {
border-color: var(--muted);
}
.shape-btn.active {
border-color: var(--accent);
background: rgba(255, 106, 61, 0.1);
}
.shape-icon {
width: 24px;
height: 24px;
background: var(--ink);
}
.shape-icon.square {
border-radius: 2px;
}
.shape-icon.rounded {
border-radius: 6px;
}
.shape-icon.dots,
.shape-icon.circle {
border-radius: 50%;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.designer-grid { .designer-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -0,0 +1,514 @@
<template>
<AppLayout>
<div class="qrcode-detail">
<header class="page-header">
<div class="header-left">
<router-link to="/qrcodes" class="back-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back to QR Codes
</router-link>
<h1>{{ qrCode?.name || 'QR Code' }}</h1>
</div>
<div class="header-actions">
<router-link :to="`/qrcodes/${id}`" class="btn btn-secondary">
Edit Design
</router-link>
</div>
</header>
<div v-if="loading" class="loading">Loading analytics...</div>
<template v-else-if="analytics">
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon scans">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalScans }}</p>
<p class="stat-label">Total Scans</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon visitors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.uniqueVisitors }}</p>
<p class="stat-label">Unique Visitors</p>
</div>
</div>
</div>
<!-- Period Selector -->
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
</div>
<!-- Chart -->
<section class="card chart-card">
<h2>Scans Over Time</h2>
<div class="chart-container" v-if="analytics.timeSeries?.length">
<div class="chart">
<div
v-for="(point, i) in analytics.timeSeries"
:key="i"
class="chart-bar"
:style="{ height: getBarHeight(point.scans) + '%' }"
:title="`${point.date}: ${point.scans} scans`"
></div>
</div>
</div>
<div v-else class="empty-state">
<p>No scan data for this period</p>
</div>
</section>
<!-- Breakdowns -->
<div class="breakdown-grid">
<!-- Device Breakdown -->
<section class="card">
<h2>Devices</h2>
<div v-if="Object.keys(analytics.deviceBreakdown).length" class="breakdown-list">
<div
v-for="(count, device) in analytics.deviceBreakdown"
:key="device"
class="breakdown-item"
>
<span class="breakdown-label">{{ device }}</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No device data</p>
</div>
</section>
<!-- Country Breakdown -->
<section class="card">
<h2>Countries</h2>
<div v-if="Object.keys(analytics.countryBreakdown).length" class="breakdown-list">
<div
v-for="(count, country) in analytics.countryBreakdown"
:key="country"
class="breakdown-item"
>
<span class="breakdown-label">
{{ getCountryFlag(country) }} {{ getCountryName(country) }}
</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No country data</p>
</div>
</section>
<!-- Referrer Breakdown -->
<section class="card">
<h2>Referrers</h2>
<div v-if="Object.keys(analytics.referrerBreakdown).length" class="breakdown-list">
<div
v-for="(count, referrer) in analytics.referrerBreakdown"
:key="referrer"
class="breakdown-item"
>
<span class="breakdown-label">{{ referrer }}</span>
<div class="breakdown-bar-container">
<div
class="breakdown-bar"
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
></div>
</div>
<span class="breakdown-count">{{ count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No referrer data</p>
</div>
</section>
</div>
</template>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const id = computed(() => route.params.id);
const loading = ref(true);
const error = ref('');
const qrCode = ref(null);
const analytics = ref(null);
const period = ref('7d');
const periods = [
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: 'all', label: 'All' }
];
const countryNames = {
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands'
};
onMounted(() => {
loadData();
});
async function loadData() {
loading.value = true;
error.value = '';
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
if (!workspaceId) return;
const [qrData, analyticsData] = await Promise.all([
api.getQRCode(workspaceId, id.value),
api.getQRCodeAnalytics(workspaceId, id.value, period.value)
]);
qrCode.value = qrData;
analytics.value = analyticsData;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function setPeriod(newPeriod) {
period.value = newPeriod;
loading.value = true;
try {
const workspaceId = workspaceStore.currentWorkspace?.id;
analytics.value = await api.getQRCodeAnalytics(workspaceId, id.value, newPeriod);
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
function getBarHeight(value) {
if (!analytics.value?.timeSeries?.length) return 0;
const max = Math.max(...analytics.value.timeSeries.map(p => p.scans));
return max > 0 ? (value / max) * 100 : 0;
}
function getBreakdownPercent(count, total) {
return total > 0 ? (count / total) * 100 : 0;
}
function getCountryFlag(code) {
const codePoints = code
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountryName(code) {
return countryNames[code] || code;
}
</script>
<style scoped>
.qrcode-detail {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
text-decoration: none;
}
.back-link:hover {
color: #374151;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.scans {
background: #dbeafe;
color: #2563eb;
}
.stat-icon.visitors {
background: #d1fae5;
color: #059669;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.period-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.period-selector button {
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
background: white;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.period-selector button:hover {
background: #f9fafb;
}
.period-selector button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.chart-container {
height: 200px;
display: flex;
align-items: flex-end;
}
.chart {
display: flex;
align-items: flex-end;
gap: 4px;
width: 100%;
height: 100%;
}
.chart-bar {
flex: 1;
background: #3b82f6;
border-radius: 4px 4px 0 0;
min-height: 4px;
transition: height 0.3s ease;
}
.breakdown-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.breakdown-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-item {
display: flex;
align-items: center;
gap: 1rem;
}
.breakdown-label {
width: 120px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breakdown-bar-container {
flex: 1;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.breakdown-bar {
height: 100%;
background: #3b82f6;
border-radius: 4px;
}
.breakdown-count {
width: 50px;
text-align: right;
font-size: 0.875rem;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.empty-state p {
margin: 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover {
background: #f9fafb;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #dc2626;
margin-top: 1rem;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.breakdown-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,991 @@
<template>
<AppLayout>
<div class="settings">
<header class="page-header">
<h1>Account Settings</h1>
<p class="subtitle">Manage your account and preferences</p>
</header>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Profile Section -->
<section class="settings-section">
<div class="section-header">
<h2>Profile</h2>
</div>
<div class="section-content">
<form @submit.prevent="updateProfile">
<div class="form-group">
<label for="email">Email Address</label>
<input
id="email"
v-model="profile.email"
type="email"
required
/>
<p v-if="!profile.isVerified" class="form-warning">
Your email is not verified.
<button type="button" class="link-btn" @click="resendVerification" :disabled="resendingVerification">
{{ resendingVerification ? 'Sending...' : 'Resend verification email' }}
</button>
</p>
</div>
<div v-if="profileError" class="error-message">{{ profileError }}</div>
<div v-if="profileSuccess" class="success-message">{{ profileSuccess }}</div>
<button type="submit" class="btn btn-primary" :disabled="savingProfile">
{{ savingProfile ? 'Saving...' : 'Save Changes' }}
</button>
</form>
</div>
</section>
<!-- Password Section -->
<section class="settings-section">
<div class="section-header">
<h2>Change Password</h2>
</div>
<div class="section-content">
<form @submit.prevent="changePassword">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input
id="currentPassword"
v-model="passwordForm.currentPassword"
type="password"
required
/>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input
id="newPassword"
v-model="passwordForm.newPassword"
type="password"
required
minlength="8"
/>
<p class="form-hint">At least 8 characters</p>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input
id="confirmPassword"
v-model="passwordForm.confirmPassword"
type="password"
required
/>
</div>
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
<div v-if="passwordSuccess" class="success-message">{{ passwordSuccess }}</div>
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
{{ changingPassword ? 'Changing...' : 'Change Password' }}
</button>
</form>
</div>
</section>
<!-- API Keys Section -->
<section class="settings-section">
<div class="section-header">
<h2>API Keys</h2>
<button class="btn btn-primary btn-sm" @click="showCreateKeyModal = true">
Create Key
</button>
</div>
<div class="section-content">
<p class="section-description">Generate API keys to access TrakQR programmatically. Keys are scoped to your current workspace.</p>
<div v-if="loadingKeys" class="loading-inline">Loading API keys...</div>
<div v-else-if="apiKeys.length === 0" class="empty-state-inline">
<p>No API keys yet. Create one to get started.</p>
</div>
<div v-else class="api-keys-list">
<div v-for="key in apiKeys" :key="key.id" class="api-key-item">
<div class="api-key-info">
<span class="api-key-name">{{ key.name }}</span>
<code class="api-key-prefix">{{ key.keyPrefix }}</code>
</div>
<div class="api-key-meta">
<span v-if="key.lastUsedAt" class="api-key-used">
Last used {{ formatDate(key.lastUsedAt) }}
</span>
<span v-else class="api-key-used">Never used</span>
<span v-if="key.expiresAt" :class="['api-key-expiry', { expired: isExpired(key.expiresAt) }]">
{{ isExpired(key.expiresAt) ? 'Expired' : `Expires ${formatDate(key.expiresAt)}` }}
</span>
</div>
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteKey(key)" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
</section>
<!-- Danger Zone -->
<section class="settings-section danger-zone">
<div class="section-header">
<h2>Danger Zone</h2>
</div>
<div class="section-content">
<div class="danger-item">
<div>
<h4>Delete Account</h4>
<p>Permanently delete your account and all associated data. This action cannot be undone.</p>
</div>
<button class="btn btn-danger" @click="showDeleteModal = true">
Delete Account
</button>
</div>
</div>
</section>
</template>
<!-- Create API Key Modal -->
<div v-if="showCreateKeyModal" class="modal-overlay" @click.self="showCreateKeyModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2 class="modal-title-normal">Create API Key</h2>
<button class="close-btn" @click="showCreateKeyModal = false">&times;</button>
</div>
<form @submit.prevent="createApiKey">
<div class="modal-body">
<div class="form-group">
<label for="keyName">Key Name</label>
<input
id="keyName"
v-model="newKeyName"
type="text"
required
placeholder="e.g., Production Server"
/>
</div>
<div class="form-group">
<label for="keyExpiry">Expiration (optional)</label>
<select id="keyExpiry" v-model="newKeyExpiry">
<option value="">Never expires</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">1 year</option>
</select>
</div>
<div v-if="createKeyError" class="error-message">{{ createKeyError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showCreateKeyModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="creatingKey">
{{ creatingKey ? 'Creating...' : 'Create Key' }}
</button>
</div>
</form>
</div>
</div>
<!-- Show New Key Modal -->
<div v-if="newlyCreatedKey" class="modal-overlay">
<div class="modal modal-sm">
<div class="modal-header">
<h2 class="modal-title-normal">API Key Created</h2>
</div>
<div class="modal-body">
<p class="key-warning">
Copy your API key now. You won't be able to see it again!
</p>
<div class="key-display">
<code>{{ newlyCreatedKey }}</code>
<button type="button" class="btn btn-icon" @click="copyKey" :title="keyCopied ? 'Copied!' : 'Copy'">
<svg v-if="!keyCopied" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" @click="newlyCreatedKey = null">Done</button>
</div>
</div>
</div>
<!-- Delete API Key Modal -->
<div v-if="keyToDelete" class="modal-overlay" @click.self="keyToDelete = null">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete API Key?</h2>
<button class="close-btn" @click="keyToDelete = null">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the API key "{{ keyToDelete.name }}"? Any applications using this key will stop working.</p>
<div v-if="deleteKeyError" class="error-message">{{ deleteKeyError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="keyToDelete = null">Cancel</button>
<button type="button" class="btn btn-danger" @click="deleteApiKey" :disabled="deletingKey">
{{ deletingKey ? 'Deleting...' : 'Delete Key' }}
</button>
</div>
</div>
</div>
<!-- Delete Account Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Delete Account</h2>
<button class="close-btn" @click="showDeleteModal = false">&times;</button>
</div>
<form @submit.prevent="deleteAccount">
<div class="modal-body">
<p class="delete-warning">
This will permanently delete your account, all workspaces, links, QR codes, and analytics data.
This action <strong>cannot be undone</strong>.
</p>
<div class="form-group">
<label for="deletePassword">Enter your password to confirm</label>
<input
id="deletePassword"
v-model="deletePassword"
type="password"
required
placeholder="Your password"
/>
</div>
<div v-if="deleteError" class="error-message">{{ deleteError }}</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button type="submit" class="btn btn-danger" :disabled="deleting">
{{ deleting ? 'Deleting...' : 'Delete My Account' }}
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { useWorkspaceStore } from '../../stores/workspace';
import AppLayout from '../../components/layout/AppLayout.vue';
const router = useRouter();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const loading = ref(true);
const profile = ref({ email: '', isVerified: false });
const savingProfile = ref(false);
const profileError = ref('');
const profileSuccess = ref('');
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const changingPassword = ref(false);
const passwordError = ref('');
const passwordSuccess = ref('');
const showDeleteModal = ref(false);
const deletePassword = ref('');
const deleting = ref(false);
const deleteError = ref('');
// API Keys state
const apiKeys = ref([]);
const loadingKeys = ref(false);
const showCreateKeyModal = ref(false);
const newKeyName = ref('');
const newKeyExpiry = ref('');
const creatingKey = ref(false);
const createKeyError = ref('');
const newlyCreatedKey = ref(null);
const keyCopied = ref(false);
const keyToDelete = ref(null);
const deletingKey = ref(false);
const deleteKeyError = ref('');
onMounted(async () => {
try {
const data = await api.getProfile();
profile.value = data;
await loadApiKeys();
} catch (err) {
profileError.value = err.message;
} finally {
loading.value = false;
}
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await loadApiKeys();
}
});
async function loadApiKeys() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
loadingKeys.value = true;
try {
const result = await api.listApiKeys(workspaceId);
apiKeys.value = result.apiKeys;
} catch (err) {
console.error('Failed to load API keys:', err);
} finally {
loadingKeys.value = false;
}
}
async function updateProfile() {
savingProfile.value = true;
profileError.value = '';
profileSuccess.value = '';
try {
const data = await api.updateProfile({ email: profile.value.email });
profile.value = data;
profileSuccess.value = 'Profile updated successfully';
} catch (err) {
profileError.value = err.message;
} finally {
savingProfile.value = false;
}
}
async function changePassword() {
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
passwordError.value = 'Passwords do not match';
return;
}
changingPassword.value = true;
passwordError.value = '';
passwordSuccess.value = '';
try {
await api.changePassword(
passwordForm.value.currentPassword,
passwordForm.value.newPassword
);
passwordSuccess.value = 'Password changed successfully';
passwordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '' };
} catch (err) {
passwordError.value = err.message;
} finally {
changingPassword.value = false;
}
}
const resendingVerification = ref(false);
async function resendVerification() {
resendingVerification.value = true;
profileError.value = '';
try {
await api.resendVerification();
profileSuccess.value = 'Verification email sent! Check your inbox.';
} catch (err) {
profileError.value = err.message;
} finally {
resendingVerification.value = false;
}
}
async function deleteAccount() {
deleting.value = true;
deleteError.value = '';
try {
await api.deleteAccount(deletePassword.value);
authStore.logout();
router.push('/');
} catch (err) {
deleteError.value = err.message;
} finally {
deleting.value = false;
}
}
// API Key functions
async function createApiKey() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
creatingKey.value = true;
createKeyError.value = '';
try {
let expiresAt = null;
if (newKeyExpiry.value) {
const date = new Date();
date.setDate(date.getDate() + parseInt(newKeyExpiry.value));
expiresAt = date.toISOString();
}
const result = await api.createApiKey(workspaceId, newKeyName.value, expiresAt);
newlyCreatedKey.value = result.key;
apiKeys.value.unshift({
id: result.id,
name: result.name,
keyPrefix: result.keyPrefix,
scopes: result.scopes,
expiresAt: result.expiresAt,
createdAt: result.createdAt,
isActive: true,
});
showCreateKeyModal.value = false;
newKeyName.value = '';
newKeyExpiry.value = '';
} catch (err) {
createKeyError.value = err.message;
} finally {
creatingKey.value = false;
}
}
async function copyKey() {
try {
await navigator.clipboard.writeText(newlyCreatedKey.value);
keyCopied.value = true;
setTimeout(() => {
keyCopied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
function confirmDeleteKey(key) {
keyToDelete.value = key;
deleteKeyError.value = '';
}
async function deleteApiKey() {
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId || !keyToDelete.value) return;
deletingKey.value = true;
deleteKeyError.value = '';
try {
await api.deleteApiKey(workspaceId, keyToDelete.value.id);
apiKeys.value = apiKeys.value.filter(k => k.id !== keyToDelete.value.id);
keyToDelete.value = null;
} catch (err) {
deleteKeyError.value = err.message;
} finally {
deletingKey.value = false;
}
}
function formatDate(date) {
if (!date) return '';
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function isExpired(date) {
if (!date) return false;
return new Date(date) < new Date();
}
</script>
<style scoped>
.settings {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.settings-section {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.section-header h2 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
background: #e5e7eb;
color: #6b7280;
border-radius: 4px;
}
.section-content {
padding: 1.5rem;
}
.section-content.muted {
color: #6b7280;
}
.section-content.muted p {
margin: 0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-of-type {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
font-size: 0.75rem;
color: #6b7280;
margin: 0.5rem 0 0;
}
.form-warning {
font-size: 0.875rem;
color: #d97706;
margin: 0.5rem 0 0;
}
.link-btn {
background: none;
border: none;
color: #3b82f6;
text-decoration: underline;
cursor: pointer;
font-size: inherit;
}
.link-btn:hover {
color: #2563eb;
}
/* Danger Zone */
.danger-zone .section-header {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.danger-zone .section-header h2 {
color: #dc2626;
}
.danger-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
}
.danger-item h4 {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.danger-item p {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Messages */
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.success-message {
background: #d1fae5;
border: 1px solid #a7f3d0;
border-radius: 8px;
padding: 0.75rem;
color: #065f46;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 420px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: #dc2626;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
}
.close-btn:hover {
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
}
.delete-warning {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #991b1b;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
/* API Keys */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.section-description {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1rem;
}
.loading-inline {
color: #6b7280;
font-size: 0.875rem;
padding: 1rem 0;
}
.empty-state-inline {
color: #6b7280;
font-size: 0.875rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
text-align: center;
}
.empty-state-inline p {
margin: 0;
}
.api-keys-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.api-key-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
.api-key-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.api-key-name {
font-weight: 500;
font-size: 0.9rem;
}
.api-key-prefix {
font-family: monospace;
font-size: 0.8rem;
color: #6b7280;
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
width: fit-content;
}
.api-key-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.api-key-expiry.expired {
color: #dc2626;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
}
.btn-icon:hover {
background: #e5e7eb;
}
.btn-danger-icon:hover {
background: #fee2e2;
color: #dc2626;
}
.modal-title-normal {
color: #111827 !important;
}
.key-warning {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 0.75rem;
color: #92400e;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.key-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1f2937;
border-radius: 8px;
padding: 0.75rem;
}
.key-display code {
flex: 1;
color: #10b981;
font-family: monospace;
font-size: 0.8rem;
word-break: break-all;
}
.key-display .btn-icon {
color: #9ca3af;
flex-shrink: 0;
}
.key-display .btn-icon:hover {
color: white;
background: transparent;
}
.form-group select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
background: white;
}
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
@media (max-width: 640px) {
.danger-item {
flex-direction: column;
align-items: flex-start;
}
.api-key-item {
flex-direction: column;
align-items: flex-start;
}
.api-key-meta {
flex-direction: row;
align-items: center;
gap: 1rem;
width: 100%;
justify-content: space-between;
}
}
</style>