feat: add database backed membership tiers
All checks were successful
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-07 20:29:53 -04:00
parent db16e79d9f
commit 6d92119c9c
23 changed files with 3512 additions and 30 deletions

View File

@@ -164,6 +164,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/organization-membership-tiers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesOrganizationsHandlersListOrganizationMembershipTiersHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/organizations/{organizationId}/membership-tier": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierHandler"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/notifications": {
parameters: {
query?: never;
@@ -1223,16 +1255,39 @@ export interface components {
id?: string;
name?: string;
logoUrl?: string | null;
membershipTier?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"] | null;
/** Format: guid */
ownerUserId?: string;
currentUserPermissions?: string[];
members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][];
workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][];
usage?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageDto"] | null;
availableMembershipTiers?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"][];
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto: {
/** Format: guid */
id?: string;
key?: string;
name?: string;
description?: string;
/** Format: int32 */
monthlyPriceCents?: number | null;
/** Format: int32 */
workspaceLimit?: number | null;
/** Format: int32 */
activeContentLimit?: number | null;
/** Format: int32 */
memberLimit?: number | null;
/** Format: int32 */
externalReviewerLimit?: number | null;
isCustom?: boolean;
/** Format: int32 */
sortOrder?: number;
};
SocializeApiModulesOrganizationsHandlersOrganizationUsageDto: {
planKey?: string;
planName?: string;
items?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageItemDto"][];
};
@@ -1245,10 +1300,16 @@ export interface components {
};
SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest: {
name: string;
/** Format: guid */
membershipTierId?: string | null;
};
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
name: string;
};
SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierRequest: {
/** Format: guid */
membershipTierId: string;
};
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
/** Format: guid */
id?: string;
@@ -2438,6 +2499,75 @@ export interface operations {
};
};
};
SocializeApiModulesOrganizationsHandlersListOrganizationMembershipTiersHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierHandler: {
parameters: {
query?: never;
header?: never;
path: {
organizationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
parameters: {
query?: {

View File

@@ -18,12 +18,15 @@ export const useOrganizationStore = defineStore('organization', () => {
const client = useClient();
const organizations = ref([]);
const membershipTiers = ref([]);
const selectedOrganizationId = ref(null);
const detailsById = ref({});
const isLoading = ref(false);
const isLoadingDetails = ref(false);
const isCreating = ref(false);
const isLoadingMembershipTiers = ref(false);
const isSaving = ref(false);
const isUpdatingMembershipTier = ref(false);
const isAddingMember = ref(false);
const isUploadingLogo = ref(false);
const error = ref(null);
@@ -90,6 +93,28 @@ export const useOrganizationStore = defineStore('organization', () => {
}
}
async function fetchMembershipTiers() {
if (membershipTiers.value.length > 0) {
return membershipTiers.value;
}
isLoadingMembershipTiers.value = true;
error.value = null;
try {
const response = await client.get('/api/organization-membership-tiers');
membershipTiers.value = response.data ?? [];
return membershipTiers.value;
} catch (fetchError) {
console.error('Failed to fetch organization membership tiers:', fetchError);
membershipTiers.value = [];
error.value = 'Failed to load membership tiers.';
return [];
} finally {
isLoadingMembershipTiers.value = false;
}
}
async function fetchOrganization(organizationId) {
if (!authStore.isAuthenticated || !organizationId) {
return null;
@@ -190,6 +215,51 @@ export const useOrganizationStore = defineStore('organization', () => {
}
}
async function updateMembershipTier(organizationId, membershipTierId) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to update an organization membership tier.');
}
isUpdatingMembershipTier.value = true;
error.value = null;
try {
const response = await client.put(`/api/organizations/${organizationId}/membership-tier`, {
membershipTierId,
});
const organization = response.data;
if (organization) {
const currentDetails = detailsById.value[organizationId];
detailsById.value = {
...detailsById.value,
[organizationId]: {
...(currentDetails ?? {}),
...organization,
members: currentDetails?.members ?? organization.members ?? [],
workspaces: currentDetails?.workspaces ?? organization.workspaces ?? [],
usage: currentDetails?.usage ?? organization.usage ?? null,
availableMembershipTiers: currentDetails?.availableMembershipTiers ?? organization.availableMembershipTiers ?? [],
},
};
organizations.value = organizations.value.map(candidate =>
candidate.id === organizationId
? { ...candidate, ...organization }
: candidate
);
}
await fetchOrganization(organizationId);
return detailsById.value[organizationId] ?? organization;
} catch (updateError) {
console.error('Failed to update organization membership tier:', updateError);
error.value = 'Failed to update organization membership tier.';
throw updateError;
} finally {
isUpdatingMembershipTier.value = false;
}
}
async function addMember(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to add an organization member.');
@@ -291,13 +361,16 @@ export const useOrganizationStore = defineStore('organization', () => {
return {
organizations,
membershipTiers,
selectedOrganizationId,
activeOrganization,
detailsById,
isLoading,
isLoadingDetails,
isCreating,
isLoadingMembershipTiers,
isSaving,
isUpdatingMembershipTier,
isAddingMember,
isUploadingLogo,
error,
@@ -305,9 +378,11 @@ export const useOrganizationStore = defineStore('organization', () => {
setSelectedOrganization,
setSelectedOrganizationFromWorkspace,
fetchOrganizations,
fetchMembershipTiers,
fetchOrganization,
createOrganization,
updateOrganization,
updateMembershipTier,
addMember,
uploadLogo,
};

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiAccountArrowRightOutline, mdiDomainPlus, mdiEmailOutline } from '@mdi/js';
@@ -13,6 +13,7 @@
const createForm = reactive({
name: '',
membershipTierId: null,
});
const accessForm = reactive({
targetType: 'organization',
@@ -27,6 +28,29 @@
{ title: t('organizationOnboarding.request.types.organization'), value: 'organization' },
{ title: t('organizationOnboarding.request.types.workspace'), value: 'workspace' },
]);
const membershipTierOptions = computed(() =>
organizationStore.membershipTiers.map(tier => ({
title: tier.name,
value: tier.id,
props: {
subtitle: formatTierPrice(tier),
},
}))
);
function formatTierPrice(tier) {
if (tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined) {
return t('organizationOnboarding.create.tiers.customPrice');
}
if (tier.monthlyPriceCents === 0) {
return t('organizationOnboarding.create.tiers.freePrice');
}
return t('organizationOnboarding.create.tiers.monthlyPrice', {
price: `$${Math.round(tier.monthlyPriceCents / 100)}`,
});
}
async function createOrganization() {
if (organizationStore.isCreating) {
@@ -42,7 +66,10 @@
}
try {
await organizationStore.createOrganization({ name });
await organizationStore.createOrganization({
name,
membershipTierId: createForm.membershipTierId,
});
await Promise.all([
organizationStore.fetchOrganizations(),
workspaceStore.fetchWorkspaces(),
@@ -73,6 +100,20 @@
window.location.href = `mailto:${encodeURIComponent(accessForm.adminEmail.trim())}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
requestStatus.value = t('organizationOnboarding.request.sent');
}
onMounted(async () => {
const tiers = await organizationStore.fetchMembershipTiers();
createForm.membershipTierId = tiers[0]?.id ?? null;
});
watch(
() => organizationStore.membershipTiers,
tiers => {
if (!createForm.membershipTierId) {
createForm.membershipTierId = tiers[0]?.id ?? null;
}
}
);
</script>
<template>
@@ -114,6 +155,15 @@
variant="outlined"
hide-details
/>
<v-select
v-model="createForm.membershipTierId"
:items="membershipTierOptions"
:label="t('organizationOnboarding.create.fields.membershipTier')"
:loading="organizationStore.isLoadingMembershipTiers"
:disabled="organizationStore.isCreating"
variant="outlined"
hide-details
/>
<v-btn
:loading="organizationStore.isCreating"

View File

@@ -35,6 +35,9 @@
email: '',
role: 'Member',
});
const membershipTierForm = reactive({
membershipTierId: null,
});
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
const organizationId = computed(() => route.params.organizationId);
@@ -62,6 +65,18 @@
permissions.value.includes(organizationPermissions.createWorkspaces)
);
const usageItems = computed(() => organization.value?.usage?.items ?? []);
const membershipTierOptions = computed(() =>
(organization.value?.availableMembershipTiers?.length
? organization.value.availableMembershipTiers
: organizationStore.membershipTiers
).map(tier => ({
title: tier.name,
value: tier.id,
props: {
subtitle: formatTierSummary(tier),
},
}))
);
const visibleSections = computed(() => [
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
{ key: 'usage', icon: mdiChartBar, visible: canViewUsage.value },
@@ -79,7 +94,10 @@
return;
}
await organizationStore.fetchOrganization(organizationId.value);
await Promise.all([
organizationStore.fetchMembershipTiers(),
organizationStore.fetchOrganization(organizationId.value),
]);
}
async function submitProfile() {
@@ -156,6 +174,48 @@
}
}
async function submitMembershipTier() {
settingsError.value = null;
settingsStatus.value = null;
if (!membershipTierForm.membershipTierId) {
settingsError.value = t('organizationSettings.errors.membershipTierRequired');
return;
}
try {
await organizationStore.updateMembershipTier(
organizationId.value,
membershipTierForm.membershipTierId
);
settingsStatus.value = t('organizationSettings.tierSaved');
} catch (error) {
console.error('Failed to update organization membership tier:', error);
settingsError.value = t('organizationSettings.errors.tierSaveFailed');
}
}
function formatTierSummary(tier) {
const price = tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined
? t('organizationSettings.tiers.customPrice')
: tier.monthlyPriceCents === 0
? t('organizationSettings.tiers.freePrice')
: t('organizationSettings.tiers.monthlyPrice', {
price: `$${Math.round(tier.monthlyPriceCents / 100)}`,
});
return t('organizationSettings.tiers.summary', {
price,
workspaces: formatLimit(tier.workspaceLimit),
members: formatLimit(tier.memberLimit),
activeContent: formatLimit(tier.activeContentLimit),
});
}
function formatLimit(limit) {
return limit ?? t('organizationSettings.usage.unlimited');
}
function usagePercent(item) {
if (!item.limit) {
return 0;
@@ -171,6 +231,7 @@
organization,
currentOrganization => {
profileForm.name = currentOrganization?.name ?? '';
membershipTierForm.membershipTierId = currentOrganization?.membershipTier?.id ?? null;
},
{ immediate: true }
);
@@ -408,8 +469,30 @@
>
<div class="usage-plan">
<strong>{{ t('organizationSettings.sections.usage.planLabel') }}</strong>
<span>{{ organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
<span>{{ organization.membershipTier?.name ?? organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
</div>
<v-form
v-if="canViewBilling"
class="tier-form"
@submit.prevent="submitMembershipTier"
>
<v-select
v-model="membershipTierForm.membershipTierId"
:items="membershipTierOptions"
:label="t('organizationSettings.fields.membershipTier')"
:loading="organizationStore.isLoadingMembershipTiers"
:disabled="organizationStore.isUpdatingMembershipTier"
variant="outlined"
hide-details
/>
<v-btn
color="primary"
type="submit"
:loading="organizationStore.isUpdatingMembershipTier"
>
{{ t('organizationSettings.saveTier') }}
</v-btn>
</v-form>
<div
v-for="item in usageItems"
:key="item.key"
@@ -717,6 +800,11 @@
@apply flex flex-col gap-3;
}
.tier-form {
@apply grid gap-3 rounded-[0.75rem] p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end;
background: rgba(23, 32, 51, 0.04);
}
.usage-plan,
.usage-row {
@apply rounded-[0.75rem] p-4;

View File

@@ -399,7 +399,13 @@
"action": "Create organization",
"fields": {
"name": "Organization name",
"namePlaceholder": "Northstar Agency"
"namePlaceholder": "Northstar Agency",
"membershipTier": "Membership tier"
},
"tiers": {
"freePrice": "Free",
"monthlyPrice": "{price}/month",
"customPrice": "Custom"
},
"errors": {
"required": "Organization name is required.",
@@ -443,6 +449,8 @@
"addMember": "Add member",
"addingMember": "Adding...",
"memberAdded": "Organization member added.",
"saveTier": "Save tier",
"tierSaved": "Membership tier updated.",
"logo": {
"title": "Organization logo",
"description": "Shown in organization settings and switchers.",
@@ -457,6 +465,7 @@
"name": "Name",
"memberEmail": "Member email",
"memberRole": "Role",
"membershipTier": "Membership tier",
"createdAt": "Created"
},
"errors": {
@@ -464,7 +473,9 @@
"profileSaveFailed": "The organization profile could not be saved.",
"memberRequired": "Email and role are required to add a member.",
"memberAddFailed": "The organization member could not be added. Existing users can be added by email.",
"logoUploadFailed": "The organization logo could not be saved."
"logoUploadFailed": "The organization logo could not be saved.",
"membershipTierRequired": "Select a membership tier.",
"tierSaveFailed": "The membership tier could not be updated."
},
"sections": {
"profile": {
@@ -506,9 +517,16 @@
"items": {
"users": "Users",
"workspaces": "Workspaces",
"activeContent": "Active content"
"activeContent": "Active content",
"externalReviewers": "External reviewers"
}
},
"tiers": {
"freePrice": "Free",
"monthlyPrice": "{price}/month",
"customPrice": "Custom",
"summary": "{price} - {workspaces} workspaces, {members} members, {activeContent} active content"
},
"roles": {
"Owner": "Owner",
"Admin": "Admin",

View File

@@ -399,7 +399,13 @@
"action": "Creer l'organisation",
"fields": {
"name": "Nom de l'organisation",
"namePlaceholder": "Agence Northstar"
"namePlaceholder": "Agence Northstar",
"membershipTier": "Forfait"
},
"tiers": {
"freePrice": "Gratuit",
"monthlyPrice": "{price}/mois",
"customPrice": "Sur mesure"
},
"errors": {
"required": "Le nom de l'organisation est requis.",
@@ -443,6 +449,8 @@
"addMember": "Ajouter un membre",
"addingMember": "Ajout...",
"memberAdded": "Membre de l'organisation ajoute.",
"saveTier": "Enregistrer le forfait",
"tierSaved": "Forfait mis a jour.",
"logo": {
"title": "Logo de l'organisation",
"description": "Affiche dans les parametres et les selecteurs d'organisation.",
@@ -457,6 +465,7 @@
"name": "Nom",
"memberEmail": "Email du membre",
"memberRole": "Role",
"membershipTier": "Forfait",
"createdAt": "Cree"
},
"errors": {
@@ -464,7 +473,9 @@
"profileSaveFailed": "Le profil de l'organisation n'a pas pu etre enregistre.",
"memberRequired": "L'email et le role sont requis pour ajouter un membre.",
"memberAddFailed": "Le membre de l'organisation n'a pas pu etre ajoute. Les utilisateurs existants peuvent etre ajoutes par email.",
"logoUploadFailed": "Le logo de l'organisation n'a pas pu etre enregistre."
"logoUploadFailed": "Le logo de l'organisation n'a pas pu etre enregistre.",
"membershipTierRequired": "Selectionnez un forfait.",
"tierSaveFailed": "Le forfait n'a pas pu etre mis a jour."
},
"sections": {
"profile": {
@@ -506,9 +517,16 @@
"items": {
"users": "Utilisateurs",
"workspaces": "Espaces",
"activeContent": "Contenu actif"
"activeContent": "Contenu actif",
"externalReviewers": "Reviseurs externes"
}
},
"tiers": {
"freePrice": "Gratuit",
"monthlyPrice": "{price}/mois",
"customPrice": "Sur mesure",
"summary": "{price} - {workspaces} espaces, {members} membres, {activeContent} contenus actifs"
},
"roles": {
"Owner": "Proprietaire",
"Admin": "Administrateur",