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

@@ -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;