feat: localize membership tier display
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,7 +74,11 @@ internal class UpdateOrganizationMembershipTierHandler(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
currentUserPermissions,
|
||||
OrganizationMembershipTierDto.FromTier(membershipTier)),
|
||||
await OrganizationMembershipTierLocalization.GetLocalizedTierDtoAsync(
|
||||
dbContext,
|
||||
membershipTier,
|
||||
HttpContext,
|
||||
ct)),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user