diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts index 2fbaee3e..24ff6051 100644 --- a/apps/api/src/services/organization.ts +++ b/apps/api/src/services/organization.ts @@ -15,6 +15,9 @@ import { getOrganizationAudienceMember, getOrganizationById, getOrganizationBySiteName, + getOrganizationMemberByIdAndOrg, + getOrganizationMemberIdByOrgAndProfile, + getOrganizationPlanBySubscriptionId, getOrganizationTeam, getOrganizations, revokeActiveOrganizationInvitesByEmails, @@ -41,7 +44,7 @@ import { PLAN } from '@cio/utils/plans'; import { ROLE } from '@cio/utils/constants'; import { createOrganizationWithOwner } from '@api/services/onboarding'; import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status'; -import { getProfileById } from '@cio/db/queries/auth'; +import { getProfileById, getProfileByEmail } from '@cio/db/queries/auth'; import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite'; import { trustCustomDomainHostname, untrustCustomDomainHostname } from '@cio/db/utils'; @@ -520,6 +523,45 @@ export async function getOrgSetupData(siteName: string) { } } +async function resolveOrgPlanTriggeredBy(params: { + checkoutOrgId: string; + targetOrgId: string; + triggeredBy: number; + payload: Record | null | undefined; +}): Promise { + const { checkoutOrgId, targetOrgId, triggeredBy, payload } = params; + const checkoutMember = await getOrganizationMemberByIdAndOrg(triggeredBy, checkoutOrgId); + + if (checkoutMember?.profileId) { + if (targetOrgId === checkoutOrgId) { + return triggeredBy; + } + + const primaryMemberId = await getOrganizationMemberIdByOrgAndProfile(targetOrgId, checkoutMember.profileId); + + if (primaryMemberId) { + return primaryMemberId; + } + } + + const customer = payload?.customer; + + if (customer && typeof customer === 'object' && 'email' in customer && typeof customer.email === 'string') { + const profile = await getProfileByEmail(customer.email); + const memberId = profile ? await getOrganizationMemberIdByOrgAndProfile(targetOrgId, profile.id) : null; + + if (memberId) { + return memberId; + } + } + + throw new AppError( + 'Could not resolve organization member for plan', + ErrorCodes.ORG_PLAN_CREATE_FAILED, + 400 + ); +} + /** * Creates a new organization plan * @param data - Organization plan creation data @@ -527,22 +569,42 @@ export async function getOrgSetupData(siteName: string) { */ export async function createOrgPlan(data: TNewOrganizationPlan) { try { + if (!data.orgId || data.triggeredBy == null || !data.subscriptionId) { + throw new AppError('Missing organization plan fields', ErrorCodes.ORG_PLAN_CREATE_FAILED, 400); + } + + const existingPlan = await getOrganizationPlanBySubscriptionId(data.subscriptionId); + + if (existingPlan) { + return existingPlan; + } + // Subscriptions always attach to the primary workspace, even if a // secondary org id was supplied at checkout. - const primary = data.orgId ? await getAccountPrimary(data.orgId) : null; + const primary = await getAccountPrimary(data.orgId); const targetOrgId = primary?.id ?? data.orgId; + const triggeredBy = await resolveOrgPlanTriggeredBy({ + checkoutOrgId: data.orgId, + targetOrgId, + triggeredBy: data.triggeredBy, + payload: data.payload as Record | null | undefined + }); const plan = await createOrganizationPlan({ orgId: targetOrgId, planName: data.planName, subscriptionId: data.subscriptionId, - triggeredBy: data.triggeredBy, + triggeredBy, payload: data.payload, isActive: true, provider: 'polar' }); return plan; } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError( error instanceof Error ? error.message : 'Failed to create organization plan', ErrorCodes.ORG_PLAN_CREATE_FAILED, diff --git a/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte b/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte index 060f5418..0ceaea32 100644 --- a/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte +++ b/apps/dashboard/src/lib/features/ui/upgrade-modal.svelte @@ -32,7 +32,7 @@ }); async function handleClick(plan, planName: string) { - if (plan.CTA.IS_DISABLED || !$profile.id || !$profile.email) { + if (plan.CTA.IS_DISABLED || !$profile.id || !$profile.email || !$currentOrg.memberId) { return; } @@ -52,7 +52,7 @@ checkoutURL.searchParams.set( 'metadata', JSON.stringify({ - triggeredBy: $currentOrg.roleId, + triggeredBy: $currentOrg.memberId, orgId: $currentOrg.id, orgSlug: $currentOrg.siteName }) diff --git a/apps/dashboard/src/lib/utils/store/org.ts b/apps/dashboard/src/lib/utils/store/org.ts index 39eec5ed..2c5f758d 100644 --- a/apps/dashboard/src/lib/utils/store/org.ts +++ b/apps/dashboard/src/lib/utils/store/org.ts @@ -48,6 +48,7 @@ export const currentOrg: Writable = writable({ parentOrganizationId: null, plans: [], readOnlyUntil: null, + memberId: 0, roleId: 0, settings: {}, siteName: '', diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index 5753af69..55add7f0 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -42,6 +42,7 @@ export const getOrganizationByProfileId = async (profileId: string): Promise; } @@ -72,6 +74,7 @@ export const getOrganizationByProfileId = async (profileId: string): Promise { + try { + const [row] = await dbClient + .select({ + id: schema.organizationmember.id, + profileId: schema.organizationmember.profileId + }) + .from(schema.organizationmember) + .where( + and(eq(schema.organizationmember.id, memberId), eq(schema.organizationmember.organizationId, organizationId)) + ) + .limit(1); + + return row ?? null; + } catch (error) { + console.error('getOrganizationMemberByIdAndOrg error:', error); + throw new Error( + `Failed to resolve organization membership: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + export async function getOrganizationMemberIdByOrgAndProfile( organizationId: string, profileId: string, @@ -870,6 +899,26 @@ export const getOrganizationCount = async (): Promise => { * @param dbClient Optional DB or transaction client for use within transactions * @returns Created organization plan record */ +export const getOrganizationPlanBySubscriptionId = async ( + subscriptionId: string, + dbClient: DbOrTxClient = db +): Promise => { + try { + const [plan] = await dbClient + .select() + .from(schema.organizationPlan) + .where(eq(schema.organizationPlan.subscriptionId, subscriptionId)) + .limit(1); + + return plan ?? null; + } catch (error) { + console.error('getOrganizationPlanBySubscriptionId error:', error); + throw new Error( + `Failed to fetch organization plan: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +}; + export const createOrganizationPlan = async ( data: TNewOrganizationPlan, dbClient: DbOrTxClient = db diff --git a/packages/db/src/queries/organization/types.ts b/packages/db/src/queries/organization/types.ts index 5f60a1b4..ae85743e 100644 --- a/packages/db/src/queries/organization/types.ts +++ b/packages/db/src/queries/organization/types.ts @@ -5,6 +5,7 @@ export type OrganizationPlan = Pick