feat: localize membership tier display
All checks were successful
deploy-socialize / image (push) Successful in 1m11s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-07 20:43:08 -04:00
parent 6d92119c9c
commit 7a8a0a44bf
17 changed files with 2937 additions and 72 deletions

View File

@@ -4,8 +4,6 @@ internal class OrganizationMembershipTier
{
public Guid Id { get; init; }
public required string Key { get; set; }
public required string Name { get; set; }
public required string Description { get; set; }
public int? MonthlyPriceCents { get; set; }
public int? WorkspaceLimit { get; set; }
public int? ActiveContentLimit { get; set; }

View File

@@ -13,8 +13,6 @@ internal static class OrganizationMembershipTierSeed
{
Id = FreeId,
Key = "free",
Name = "Free",
Description = "For trying Socialize on one real approval workflow.",
MonthlyPriceCents = 0,
WorkspaceLimit = 1,
ActiveContentLimit = 3,
@@ -26,8 +24,6 @@ internal static class OrganizationMembershipTierSeed
{
Id = FreelanceId,
Key = "freelance",
Name = "Freelance",
Description = "For solo operators managing recurring client reviews.",
MonthlyPriceCents = 1900,
WorkspaceLimit = 3,
ActiveContentLimit = 25,
@@ -39,8 +35,6 @@ internal static class OrganizationMembershipTierSeed
{
Id = AgencyId,
Key = "agency",
Name = "Agency",
Description = "For agencies that need repeatable client approval operations.",
MonthlyPriceCents = 7900,
WorkspaceLimit = 15,
ActiveContentLimit = 250,
@@ -52,8 +46,6 @@ internal static class OrganizationMembershipTierSeed
{
Id = EnterpriseId,
Key = "enterprise",
Name = "Enterprise",
Description = "For larger organizations with governance and access needs.",
MonthlyPriceCents = null,
WorkspaceLimit = null,
ActiveContentLimit = null,
@@ -63,4 +55,72 @@ internal static class OrganizationMembershipTierSeed
SortOrder = 40,
},
];
public static readonly OrganizationMembershipTierTranslation[] Translations =
[
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000001"),
MembershipTierId = FreeId,
Culture = "en",
Name = "Free",
Description = "For trying Socialize on one real approval workflow.",
},
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000002"),
MembershipTierId = FreeId,
Culture = "fr",
Name = "Free",
Description = "Pour essayer Socialize sur un vrai workflow d'approbation.",
},
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000003"),
MembershipTierId = FreelanceId,
Culture = "en",
Name = "Freelance",
Description = "For solo operators managing recurring client reviews.",
},
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000004"),
MembershipTierId = FreelanceId,
Culture = "fr",
Name = "Freelance",
Description = "Pour les independants qui gerent des revisions client recurrentes.",
},
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000005"),
MembershipTierId = AgencyId,
Culture = "en",
Name = "Agency",
Description = "For agencies that need repeatable client approval operations.",
},
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000006"),
MembershipTierId = AgencyId,
Culture = "fr",
Name = "Agency",
Description = "Pour les agences qui veulent des operations d'approbation client repetables.",
},
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000007"),
MembershipTierId = EnterpriseId,
Culture = "en",
Name = "Enterprise",
Description = "For larger organizations with governance and access needs.",
},
new()
{
Id = Guid.Parse("20000000-0000-0001-0000-000000000008"),
MembershipTierId = EnterpriseId,
Culture = "fr",
Name = "Enterprise",
Description = "Pour les grandes organisations avec des besoins de gouvernance et d'acces.",
},
];
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Organizations.Data;
internal class OrganizationMembershipTierTranslation
{
public Guid Id { get; init; }
public Guid MembershipTierId { get; set; }
public required string Culture { get; set; }
public required string Name { get; set; }
public required string Description { get; set; }
}

View File

@@ -30,13 +30,26 @@ internal static class OrganizationModelConfiguration
tier.ToTable("OrganizationMembershipTiers");
tier.HasKey(x => x.Id);
tier.Property(x => x.Key).HasMaxLength(64).IsRequired();
tier.Property(x => x.Name).HasMaxLength(128).IsRequired();
tier.Property(x => x.Description).HasMaxLength(512).IsRequired();
tier.HasIndex(x => x.Key).IsUnique();
tier.HasIndex(x => x.SortOrder);
tier.HasData(OrganizationMembershipTierSeed.Tiers);
});
modelBuilder.Entity<OrganizationMembershipTierTranslation>(translation =>
{
translation.ToTable("OrganizationMembershipTierTranslations");
translation.HasKey(x => x.Id);
translation.Property(x => x.Culture).HasMaxLength(16).IsRequired();
translation.Property(x => x.Name).HasMaxLength(128).IsRequired();
translation.Property(x => x.Description).HasMaxLength(512).IsRequired();
translation.HasIndex(x => new { x.MembershipTierId, x.Culture }).IsUnique();
translation.HasOne<OrganizationMembershipTier>()
.WithMany()
.HasForeignKey(x => x.MembershipTierId)
.OnDelete(DeleteBehavior.Cascade);
translation.HasData(OrganizationMembershipTierSeed.Translations);
});
modelBuilder.Entity<OrganizationMembership>(membership =>
{
membership.ToTable("OrganizationMemberships");

View File

@@ -71,7 +71,11 @@ internal class CreateOrganizationHandler(
OrganizationDto.FromOrganization(
organization,
OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner),
OrganizationMembershipTierDto.FromTier(membershipTier)),
await OrganizationMembershipTierLocalization.GetLocalizedTierDtoAsync(
dbContext,
membershipTier,
HttpContext,
ct)),
StatusCodes.Status201Created,
ct);
}

View File

@@ -47,14 +47,17 @@ internal class GetOrganizationHandler(
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(organizationId, ct);
OrganizationMembershipTier membershipTier = await GetMembershipTierAsync(organization.MembershipTierId, ct);
OrganizationUsageDto usage = await GetUsageAsync(organization, membershipTier, ct);
IReadOnlyCollection<OrganizationMembershipTierDto> availableMembershipTiers = await GetAvailableMembershipTiersAsync(ct);
OrganizationMembershipTierDto membershipTierDto =
await OrganizationMembershipTierLocalization.GetLocalizedTierDtoAsync(dbContext, membershipTier, HttpContext, ct);
OrganizationUsageDto usage = await GetUsageAsync(organization, membershipTier, membershipTierDto, ct);
IReadOnlyCollection<OrganizationMembershipTierDto> availableMembershipTiers =
await OrganizationMembershipTierLocalization.GetLocalizedTierDtosAsync(dbContext, HttpContext, ct);
await SendOkAsync(
OrganizationDto.FromOrganization(
organization,
currentUserPermissions,
OrganizationMembershipTierDto.FromTier(membershipTier),
membershipTierDto,
members,
workspaces,
usage,
@@ -107,6 +110,7 @@ internal class GetOrganizationHandler(
private async Task<OrganizationUsageDto> GetUsageAsync(
Organization organization,
OrganizationMembershipTier membershipTier,
OrganizationMembershipTierDto membershipTierDto,
CancellationToken ct)
{
Guid[] workspaceIds = await dbContext.Workspaces
@@ -138,7 +142,7 @@ internal class GetOrganizationHandler(
return new OrganizationUsageDto(
membershipTier.Key,
membershipTier.Name,
membershipTierDto.Name,
[
new OrganizationUsageItemDto("users", userCount, membershipTier.MemberLimit),
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, membershipTier.WorkspaceLimit),
@@ -155,18 +159,6 @@ internal class GetOrganizationHandler(
.SingleAsync(tier => tier.Id == OrganizationMembershipTierSeed.FreeId, ct);
}
private async Task<IReadOnlyCollection<OrganizationMembershipTierDto>> GetAvailableMembershipTiersAsync(CancellationToken ct)
{
List<OrganizationMembershipTier> tiers = await dbContext.OrganizationMembershipTiers
.OrderBy(tier => tier.SortOrder)
.ThenBy(tier => tier.Name)
.ToListAsync(ct);
return tiers
.Select(OrganizationMembershipTierDto.FromTier)
.ToArray();
}
private async Task<int> GetExternalReviewerCountAsync(
IReadOnlyCollection<Guid> workspaceIds,
IReadOnlyCollection<Guid> organizationMemberUserIds,

View File

@@ -26,17 +26,11 @@ internal class GetOrganizationsHandler(
.OrderBy(organization => organization.Name)
.ToListAsync(ct);
Guid[] membershipTierIds = organizations
.Select(organization => organization.MembershipTierId)
.Distinct()
.ToArray();
List<OrganizationMembershipTier> membershipTierModels = await dbContext.OrganizationMembershipTiers
.Where(tier => membershipTierIds.Contains(tier.Id))
.ToListAsync(ct);
Dictionary<Guid, OrganizationMembershipTierDto> membershipTiersById = membershipTierModels
Dictionary<Guid, OrganizationMembershipTierDto> membershipTiersById =
(await OrganizationMembershipTierLocalization.GetLocalizedTierDtosAsync(dbContext, HttpContext, ct))
.ToDictionary(
tier => tier.Id,
OrganizationMembershipTierDto.FromTier);
tier => tier);
List<OrganizationDto> response = [];
foreach (Organization organization in organizations)

View File

@@ -1,7 +1,6 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations.Handlers;
@@ -16,15 +15,8 @@ internal class ListOrganizationMembershipTiersHandler(AppDbContext dbContext)
public override async Task HandleAsync(CancellationToken ct)
{
List<OrganizationMembershipTier> tierModels = await dbContext.OrganizationMembershipTiers
.OrderBy(tier => tier.SortOrder)
.ThenBy(tier => tier.Name)
.ToListAsync(ct);
OrganizationMembershipTierDto[] tiers = tierModels
.Select(OrganizationMembershipTierDto.FromTier)
.ToArray();
await SendOkAsync(tiers, ct);
await SendOkAsync(
await OrganizationMembershipTierLocalization.GetLocalizedTierDtosAsync(dbContext, HttpContext, ct),
ct);
}
}

View File

@@ -62,13 +62,15 @@ internal record OrganizationMembershipTierDto(
bool IsCustom,
int SortOrder)
{
public static OrganizationMembershipTierDto FromTier(OrganizationMembershipTier tier)
public static OrganizationMembershipTierDto FromTier(
OrganizationMembershipTier tier,
OrganizationMembershipTierTranslation translation)
{
return new OrganizationMembershipTierDto(
tier.Id,
tier.Key,
tier.Name,
tier.Description,
translation.Name,
translation.Description,
tier.MonthlyPriceCents,
tier.WorkspaceLimit,
tier.ActiveContentLimit,

View File

@@ -74,7 +74,11 @@ internal class UpdateOrganizationMembershipTierHandler(
OrganizationDto.FromOrganization(
organization,
currentUserPermissions,
OrganizationMembershipTierDto.FromTier(membershipTier)),
await OrganizationMembershipTierLocalization.GetLocalizedTierDtoAsync(
dbContext,
membershipTier,
HttpContext,
ct)),
ct);
}
}

View File

@@ -0,0 +1,142 @@
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using Socialize.Api.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Handlers;
namespace Socialize.Api.Modules.Organizations.Services;
internal static class OrganizationMembershipTierLocalization
{
private const string DefaultCulture = "en";
private static readonly HashSet<string> SupportedCultures = new(StringComparer.OrdinalIgnoreCase)
{
"en",
"fr",
};
public static string GetRequestedCulture(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);
string header = httpContext.Request.Headers.AcceptLanguage.ToString();
if (string.IsNullOrWhiteSpace(header))
{
return DefaultCulture;
}
return header
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseLanguagePreference)
.Where(preference => preference.Culture is not null)
.OrderByDescending(preference => preference.Quality)
.Select(preference => preference.Culture!)
.FirstOrDefault(IsSupportedCulture)
?? DefaultCulture;
}
public static async Task<IReadOnlyCollection<OrganizationMembershipTierDto>> GetLocalizedTierDtosAsync(
AppDbContext dbContext,
HttpContext httpContext,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(dbContext);
ArgumentNullException.ThrowIfNull(httpContext);
string culture = GetRequestedCulture(httpContext);
List<OrganizationMembershipTier> tiers = await dbContext.OrganizationMembershipTiers
.OrderBy(tier => tier.SortOrder)
.ThenBy(tier => tier.Key)
.ToListAsync(ct);
Guid[] tierIds = tiers
.Select(tier => tier.Id)
.ToArray();
List<OrganizationMembershipTierTranslation> translations = await dbContext.OrganizationMembershipTierTranslations
.Where(translation =>
tierIds.Contains(translation.MembershipTierId) &&
(translation.Culture == culture || translation.Culture == DefaultCulture))
.ToListAsync(ct);
return tiers
.Select(tier => OrganizationMembershipTierDto.FromTier(
tier,
SelectTranslation(translations, tier.Id, culture)))
.ToArray();
}
public static async Task<OrganizationMembershipTierDto> GetLocalizedTierDtoAsync(
AppDbContext dbContext,
OrganizationMembershipTier tier,
HttpContext httpContext,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(dbContext);
ArgumentNullException.ThrowIfNull(tier);
ArgumentNullException.ThrowIfNull(httpContext);
string culture = GetRequestedCulture(httpContext);
List<OrganizationMembershipTierTranslation> translations = await dbContext.OrganizationMembershipTierTranslations
.Where(translation =>
translation.MembershipTierId == tier.Id &&
(translation.Culture == culture || translation.Culture == DefaultCulture))
.ToListAsync(ct);
return OrganizationMembershipTierDto.FromTier(tier, SelectTranslation(translations, tier.Id, culture));
}
private static OrganizationMembershipTierTranslation SelectTranslation(
IReadOnlyCollection<OrganizationMembershipTierTranslation> translations,
Guid membershipTierId,
string culture)
{
return translations.FirstOrDefault(translation =>
translation.MembershipTierId == membershipTierId &&
string.Equals(translation.Culture, culture, StringComparison.OrdinalIgnoreCase))
?? translations.First(translation =>
translation.MembershipTierId == membershipTierId &&
string.Equals(translation.Culture, DefaultCulture, StringComparison.OrdinalIgnoreCase));
}
private static LanguagePreference ParseLanguagePreference(string value)
{
string[] parts = value.Split(';', StringSplitOptions.TrimEntries);
string? culture = NormalizeCulture(parts[0]);
decimal quality = 1m;
foreach (string part in parts.Skip(1))
{
if (part.StartsWith("q=", StringComparison.OrdinalIgnoreCase) &&
decimal.TryParse(part[2..], NumberStyles.Number, CultureInfo.InvariantCulture, out decimal parsedQuality))
{
quality = parsedQuality;
}
}
return new LanguagePreference(culture, quality);
}
private static string? NormalizeCulture(string value)
{
if (string.IsNullOrWhiteSpace(value) || value == "*")
{
return null;
}
string neutralCulture = value.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0];
return SupportedCultures.FirstOrDefault(culture =>
string.Equals(culture, neutralCulture, StringComparison.OrdinalIgnoreCase));
}
private static bool IsSupportedCulture(string culture)
{
return SupportedCultures.Contains(culture);
}
private sealed record LanguagePreference(string? Culture, decimal Quality);
}