Files
trakqr/src/api/Features/Plans/Services/PlanLimitsService.cs
Jonathan Bourdon e7d96f5508 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>
2026-01-30 18:53:03 -05:00

191 lines
6.8 KiB
C#

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;
}
}