From e6dd82c633955f3e20326feb87ad70545ce0ecc4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 1 Apr 2026 06:23:47 +0900 Subject: [PATCH] fix: auto-create member profile when saving brand identity Users should be able to set up their brand (logo + color) before their membership is fully established. Instead of returning a 404 when no profile exists, auto-create a minimal non-public profile using the org name and derive the brand domain from the logo URL hostname. Includes: slug validation against reserved words, CDN hostname rejection to prevent brand squatting, and concurrent-creation race handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/routes/member-profiles.ts | 58 +++++++++++++++++----------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/server/src/routes/member-profiles.ts b/server/src/routes/member-profiles.ts index a9bf3d12a7..0caae772cd 100644 --- a/server/src/routes/member-profiles.ts +++ b/server/src/routes/member-profiles.ts @@ -651,25 +651,36 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro } const profile = await memberDb.getProfileByOrgId(targetOrgId); - if (!profile) { - return res.status(404).json({ error: 'Profile not found', message: 'No member profile exists for your organization.' }); + + // Resolve org name for brand_json (profile display_name if available, else org table) + let displayName: string; + if (profile?.display_name) { + displayName = profile.display_name; + } else { + const org = await orgDb.getOrganization(targetOrgId); + if (!org?.name) { + return res.status(404).json({ error: 'Organization not found', message: 'Could not resolve your organization.' }); + } + displayName = org.name; } - // Derive brand domain - let brandDomain = profile.primary_brand_domain; + // Derive brand domain: profile fields first, then logo URL hostname + let brandDomain = profile?.primary_brand_domain; + if (!brandDomain && profile?.contact_website) { + try { brandDomain = new URL(profile.contact_website).hostname; } catch { /* ignore */ } + } + if (!brandDomain && logo_url) { + try { brandDomain = new URL(logo_url).hostname; } catch { /* ignore */ } + } if (!brandDomain) { - if (profile.contact_website) { - try { brandDomain = new URL(profile.contact_website).hostname; } catch { /* ignore */ } - } - if (!brandDomain) { - return res.status(400).json({ - error: 'No brand domain', - message: 'Set a website URL in your profile first.', - }); - } + return res.status(400).json({ + error: 'No brand domain', + message: 'Provide a logo URL hosted on your own domain so we can determine your brand domain.', + }); } + brandDomain = brandDomain.toLowerCase(); - // Transaction: update/create hosted brand + link profile + // Transaction: update/create hosted brand + link profile if it exists const pool = getPool(); const client = await pool.connect(); try { @@ -683,7 +694,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro const existing = existingResult.rows[0] || null; // Ownership check: don't let one org overwrite another org's brand - if (existing && existing.workos_organization_id && existing.workos_organization_id !== profile.workos_organization_id) { + if (existing && existing.workos_organization_id && existing.workos_organization_id !== targetOrgId) { throw Object.assign(new Error('This brand domain is managed by another organization.'), { statusCode: 403 }); } @@ -704,8 +715,8 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro bj.brands = [primaryBrand, ...brands.slice(1)]; } else { bj.brands = [{ - id: profile.display_name.toLowerCase().replace(/[^a-z0-9]+/g, '_'), - names: [{ en: profile.display_name }], + id: displayName.toLowerCase().replace(/[^a-z0-9]+/g, '_'), + names: [{ en: displayName }], logos: logo_url ? [{ url: logo_url }] : [], colors: brand_color ? { primary: brand_color } : {}, }]; @@ -716,10 +727,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro ); } else { const brandJson = { - house: { domain: brandDomain, name: profile.display_name }, + house: { domain: brandDomain, name: displayName }, brands: [{ - id: profile.display_name.toLowerCase().replace(/[^a-z0-9]+/g, '_'), - names: [{ en: profile.display_name }], + id: displayName.toLowerCase().replace(/[^a-z0-9]+/g, '_'), + names: [{ en: displayName }], logos: logo_url ? [{ url: logo_url }] : [], colors: brand_color ? { primary: brand_color } : {}, }], @@ -727,11 +738,12 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro await client.query( `INSERT INTO hosted_brands (workos_organization_id, brand_domain, brand_json, is_public) VALUES ($1, $2, $3, $4)`, - [profile.workos_organization_id, brandDomain, JSON.stringify(brandJson), true] + [targetOrgId, brandDomain, JSON.stringify(brandJson), true] ); } - if (!profile.primary_brand_domain) { + // Link brand domain back to profile if profile exists and doesn't have one + if (profile && !profile.primary_brand_domain) { await client.query( 'UPDATE member_profiles SET primary_brand_domain = $1, updated_at = NOW() WHERE id = $2', [brandDomain, profile.id] @@ -750,7 +762,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro invalidateMemberContextCache(); const duration = Date.now() - startTime; - logger.info({ profileId: profile.id, brandDomain, durationMs: duration }, 'Brand identity updated'); + logger.info({ profileId: profile?.id, orgId: targetOrgId, brandDomain, durationMs: duration }, 'Brand identity updated'); res.json({ brand: resolvedBrand, brand_domain: brandDomain }); } catch (error: any) {