chore: correct namespaces and hiearchy

This commit is contained in:
2026-01-31 02:16:32 -05:00
parent 56d393e127
commit 19e2c22111
136 changed files with 1366 additions and 1404 deletions

View File

@@ -0,0 +1,12 @@
namespace TrackQrApi.Features.Links.Common;
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

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

View File

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

View File

@@ -0,0 +1,178 @@
using System.Security.Claims;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Models;
namespace TrackQrApi.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 (var 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

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
using System.Security.Claims;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Models;
namespace TrackQrApi.Features.Links.Endpoints;
public class ListLinksRequest
{
public Guid WorkspaceId { get; set; }
public Guid? ProjectId { get; set; }
public string? Status { get; set; }
public bool IncludeDeleted { get; set; } = false;
}
public class ListLinksEndpoint(AppDbContext db)
: Endpoint<ListLinksRequest, LinkListResponse>
{
public override void Configure()
{
Get("/workspaces/{WorkspaceId}/links");
}
public override async Task HandleAsync(ListLinksRequest req, CancellationToken ct)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// Verify workspace ownership
var workspaceExists = await db.Workspaces
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
if (!workspaceExists)
{
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
return;
}
var query = db.ShortLinks
.Where(l => l.WorkspaceId == req.WorkspaceId);
// Filter by deleted status (exclude soft-deleted by default)
if (!req.IncludeDeleted) query = query.Where(l => l.DeletedAt == null);
// Filter by project if specified
if (req.ProjectId.HasValue) query = query.Where(l => l.ProjectId == req.ProjectId.Value);
// Filter by status if specified
if (!string.IsNullOrEmpty(req.Status) && Enum.TryParse<ShortLinkStatus>(req.Status, true, out var status))
query = query.Where(l => l.Status == status);
var links = await query
.OrderByDescending(l => l.CreatedAt)
.Select(l => new LinkResponse(
l.Id,
l.WorkspaceId,
l.ProjectId,
l.DomainId,
l.Slug,
l.DestinationUrl,
l.Title,
l.Status.ToString(),
l.ExpiresAt,
l.PasswordHash != null,
l.CreatedAt,
l.UpdatedAt,
l.DeletedAt
))
.ToListAsync(ct);
await HttpContext.Response.SendAsync(new LinkListResponse(links), cancellation: ct);
}
}

View File

@@ -0,0 +1,65 @@
using System.Security.Claims;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using TrackQrApi.Data;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
namespace TrackQrApi.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, cancellation: ct);
}
}

View File

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