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

@@ -22,6 +22,8 @@ internal class AppDbContext(
{
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<OrganizationMembershipTier> OrganizationMembershipTiers => Set<OrganizationMembershipTier>();
public DbSet<OrganizationMembershipTierTranslation> OrganizationMembershipTierTranslations =>
Set<OrganizationMembershipTierTranslation>();
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();

View File

@@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class LocalizeOrganizationMembershipTiers : Migration
{
private static readonly string[] MembershipTierTranslationSeedColumns =
[
"Id",
"Culture",
"Description",
"MembershipTierId",
"Name"
];
private static readonly string[] MembershipTierColumnsToRestore =
[
"Description",
"Name"
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "Description",
table: "OrganizationMembershipTiers");
migrationBuilder.DropColumn(
name: "Name",
table: "OrganizationMembershipTiers");
migrationBuilder.CreateTable(
name: "OrganizationMembershipTierTranslations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MembershipTierId = table.Column<Guid>(type: "uuid", nullable: false),
Culture = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrganizationMembershipTierTranslations", x => x.Id);
table.ForeignKey(
name: "FK_OrganizationMembershipTierTranslations_OrganizationMembersh~",
column: x => x.MembershipTierId,
principalTable: "OrganizationMembershipTiers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "OrganizationMembershipTierTranslations",
columns: MembershipTierTranslationSeedColumns,
values: new object[,]
{
{ new Guid("20000000-0000-0001-0000-000000000001"), "en", "For trying Socialize on one real approval workflow.", new Guid("20000000-0000-0000-0000-000000000001"), "Free" },
{ new Guid("20000000-0000-0001-0000-000000000002"), "fr", "Pour essayer Socialize sur un vrai workflow d'approbation.", new Guid("20000000-0000-0000-0000-000000000001"), "Free" },
{ new Guid("20000000-0000-0001-0000-000000000003"), "en", "For solo operators managing recurring client reviews.", new Guid("20000000-0000-0000-0000-000000000002"), "Freelance" },
{ new Guid("20000000-0000-0001-0000-000000000004"), "fr", "Pour les independants qui gerent des revisions client recurrentes.", new Guid("20000000-0000-0000-0000-000000000002"), "Freelance" },
{ new Guid("20000000-0000-0001-0000-000000000005"), "en", "For agencies that need repeatable client approval operations.", new Guid("20000000-0000-0000-0000-000000000003"), "Agency" },
{ new Guid("20000000-0000-0001-0000-000000000006"), "fr", "Pour les agences qui veulent des operations d'approbation client repetables.", new Guid("20000000-0000-0000-0000-000000000003"), "Agency" },
{ new Guid("20000000-0000-0001-0000-000000000007"), "en", "For larger organizations with governance and access needs.", new Guid("20000000-0000-0000-0000-000000000004"), "Enterprise" },
{ new Guid("20000000-0000-0001-0000-000000000008"), "fr", "Pour les grandes organisations avec des besoins de gouvernance et d'acces.", new Guid("20000000-0000-0000-0000-000000000004"), "Enterprise" }
});
migrationBuilder.CreateIndex(
name: "IX_OrganizationMembershipTierTranslations_MembershipTierId_Cul~",
table: "OrganizationMembershipTierTranslations",
columns: ["MembershipTierId", "Culture"],
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropTable(
name: "OrganizationMembershipTierTranslations");
migrationBuilder.AddColumn<string>(
name: "Description",
table: "OrganizationMembershipTiers",
type: "character varying(512)",
maxLength: 512,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Name",
table: "OrganizationMembershipTiers",
type: "character varying(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000001"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For trying Socialize on one real approval workflow.", "Free" });
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000002"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For solo operators managing recurring client reviews.", "Freelance" });
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000003"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For agencies that need repeatable client approval operations.", "Agency" });
migrationBuilder.UpdateData(
table: "OrganizationMembershipTiers",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000004"),
columns: MembershipTierColumnsToRestore,
values: new object[] { "For larger organizations with governance and access needs.", "Enterprise" });
}
}
}

View File

@@ -1724,11 +1724,6 @@ namespace Socialize.Api.Migrations
b.Property<int?>("ActiveContentLimit")
.HasColumnType("integer");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int?>("ExternalReviewerLimit")
.HasColumnType("integer");
@@ -1746,11 +1741,6 @@ namespace Socialize.Api.Migrations
b.Property<int?>("MonthlyPriceCents")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
@@ -1771,13 +1761,11 @@ namespace Socialize.Api.Migrations
{
Id = new Guid("20000000-0000-0000-0000-000000000001"),
ActiveContentLimit = 3,
Description = "For trying Socialize on one real approval workflow.",
ExternalReviewerLimit = 1,
IsCustom = false,
Key = "free",
MemberLimit = 2,
MonthlyPriceCents = 0,
Name = "Free",
SortOrder = 10,
WorkspaceLimit = 1
},
@@ -1785,13 +1773,11 @@ namespace Socialize.Api.Migrations
{
Id = new Guid("20000000-0000-0000-0000-000000000002"),
ActiveContentLimit = 25,
Description = "For solo operators managing recurring client reviews.",
ExternalReviewerLimit = 10,
IsCustom = false,
Key = "freelance",
MemberLimit = 5,
MonthlyPriceCents = 1900,
Name = "Freelance",
SortOrder = 20,
WorkspaceLimit = 3
},
@@ -1799,26 +1785,120 @@ namespace Socialize.Api.Migrations
{
Id = new Guid("20000000-0000-0000-0000-000000000003"),
ActiveContentLimit = 250,
Description = "For agencies that need repeatable client approval operations.",
IsCustom = false,
Key = "agency",
MemberLimit = 25,
MonthlyPriceCents = 7900,
Name = "Agency",
SortOrder = 30,
WorkspaceLimit = 15
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000004"),
Description = "For larger organizations with governance and access needs.",
IsCustom = true,
Key = "enterprise",
Name = "Enterprise",
SortOrder = 40
});
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Culture")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid>("MembershipTierId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("MembershipTierId", "Culture")
.IsUnique();
b.ToTable("OrganizationMembershipTierTranslations", (string)null);
b.HasData(
new
{
Id = new Guid("20000000-0000-0001-0000-000000000001"),
Culture = "en",
Description = "For trying Socialize on one real approval workflow.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"),
Name = "Free"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000002"),
Culture = "fr",
Description = "Pour essayer Socialize sur un vrai workflow d'approbation.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"),
Name = "Free"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000003"),
Culture = "en",
Description = "For solo operators managing recurring client reviews.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"),
Name = "Freelance"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000004"),
Culture = "fr",
Description = "Pour les independants qui gerent des revisions client recurrentes.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"),
Name = "Freelance"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000005"),
Culture = "en",
Description = "For agencies that need repeatable client approval operations.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"),
Name = "Agency"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000006"),
Culture = "fr",
Description = "Pour les agences qui veulent des operations d'approbation client repetables.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"),
Name = "Agency"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000007"),
Culture = "en",
Description = "For larger organizations with governance and access needs.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"),
Name = "Enterprise"
},
new
{
Id = new Guid("20000000-0000-0001-0000-000000000008"),
Culture = "fr",
Description = "Pour les grandes organisations avec des besoins de gouvernance et d'acces.",
MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"),
Name = "Enterprise"
});
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
@@ -2256,6 +2336,15 @@ namespace Socialize.Api.Migrations
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null)
.WithMany()
.HasForeignKey("MembershipTierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)

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);
}

View File

@@ -0,0 +1,41 @@
# Task: Localized organization membership tier display
## Feature Spec
`docs/FEATURES/organizations.md`
## Goal
Move membership tier display names and descriptions into localized database rows so API consumers receive tier copy in the requested language with English fallback.
## Scope
- Add membership tier translation persistence for `en` and `fr`.
- Remove tier `Name` and `Description` from the base tier model.
- Select tier translation from `Accept-Language`, fallback to English.
- Send the active frontend locale as `Accept-Language` on API requests.
- Regenerate EF migration, OpenAPI, and frontend schema.
- Audit similar database-backed display strings that are currently unlocalized.
## Validation
```bash
dotnet ef migrations add LocalizeOrganizationMembershipTiers --project backend/src/Socialize.Api/Socialize.Api.csproj --startup-project backend/src/Socialize.Api/Socialize.Api.csproj
dotnet test backend/Socialize.slnx
cd frontend && npm run build
./scripts/update-openapi.sh
```
## Done When
- [x] Tier display names and descriptions are stored per culture.
- [x] Tier APIs return localized `name` and `description`.
- [x] English is used when the requested locale is missing.
- [x] Frontend API requests include the current locale.
- [x] Similar unlocalized persisted display strings are identified.
## Localization Audit
- Calendar catalog seed rows still store curated `Title` and `Description` as single-language strings. This looks similar to membership tiers because the copy is product-owned catalog metadata, not user-authored content.
- Backend-generated notification/activity messages are persisted as English sentences. They are event history, but they are still user-visible generated copy.
- Most other `Name`, `Title`, and `Description` fields found in modules are user-authored domain content, imported calendar event data, identifiers, roles, statuses, or test data and should not be translated by the platform.

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import config from '@/config.js';
import { i18n } from '@/plugins/i18n.js';
export function useClient() {
const client = axios.create({
@@ -36,6 +37,8 @@ export function useClient() {
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
}
config.headers['Accept-Language'] = i18n.global.locale.value ?? 'en';
if (config.data instanceof FormData) {
console.log(`Data is FormData, removing explicit Content-Type header for: ${config.method?.toUpperCase()} ${config.url}`);
delete config.headers['Content-Type'];