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:
94
src/api/Features/Plans/Endpoints/GetUsageEndpoint.cs
Normal file
94
src/api/Features/Plans/Endpoints/GetUsageEndpoint.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/api/Features/Plans/Services/PlanLimitsService.cs
Normal file
190
src/api/Features/Plans/Services/PlanLimitsService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user