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>
191 lines
6.8 KiB
C#
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;
|
|
}
|
|
}
|