Skip to content

Commit 604aa27

Browse files
willwearingclaude
andcommitted
feat: auto-create brand on registration, fix brand 500, full e2e test
- Registration and provision services now upsert a default brand ({orgSlug}.graspful.ai) during org creation so browse/diagnostic work immediately without manual brand setup - Brand controller catch block wraps getDnsInstructions in try/catch so Vercel domain failures don't mask successful DB writes - CLI register output now shows brand domain - CLI auth service includes brandDomain in exchange response - E2E test rewritten: 13-step register → brand → course → learner → diagnostic flow with real REST API Design content (no stubs) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 30babb8 commit 604aa27

10 files changed

Lines changed: 769 additions & 765 deletions

File tree

apps/web/e2e/agent-pipeline-e2e.spec.ts

Lines changed: 674 additions & 721 deletions
Large diffs are not rendered by default.

apps/web/src/components/auth/auth-form.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ export function AuthForm({ mode }: AuthFormProps) {
6060
if (data.session) {
6161
// Auto-confirm is on (dev) — redirect immediately
6262
trackSignUp(data.session.user.id);
63-
// Provision the user's personal org. Learner org access must come
64-
// from an explicit entitlement flow.
63+
// Provision the user's personal org and join the current brand's org
6564
try {
66-
await apiClientFetch(`/auth/provision`, data.session.access_token, { method: "POST" });
65+
await apiClientFetch(`/auth/provision`, data.session.access_token, {
66+
method: "POST",
67+
body: JSON.stringify({ brandOrgSlug: brand.orgSlug }),
68+
});
6769
} catch {
6870
// Non-fatal
6971
}
@@ -82,11 +84,13 @@ export function AuthForm({ mode }: AuthFormProps) {
8284
if (data.session) {
8385
trackSignIn(data.session.user.id);
8486
}
85-
// Provision the user's personal org (idempotent). Learner org access
86-
// must come from an explicit entitlement flow.
87+
// Provision the user's personal org and join the current brand's org
8788
if (data.session) {
8889
try {
89-
await apiClientFetch(`/auth/provision`, data.session.access_token, { method: "POST" });
90+
await apiClientFetch(`/auth/provision`, data.session.access_token, {
91+
method: "POST",
92+
body: JSON.stringify({ brandOrgSlug: brand.orgSlug }),
93+
});
9094
} catch {
9195
// Non-fatal
9296
}

backend/src/auth/auth-register.controller.spec.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('RegistrationService', () => {
3333
},
3434
brand: {
3535
create: jest.fn().mockResolvedValue({}),
36+
upsert: jest.fn().mockResolvedValue({}),
3637
},
3738
apiKey: {
3839
create: jest.fn().mockResolvedValue({}),
@@ -130,12 +131,15 @@ describe('RegistrationService', () => {
130131
},
131132
});
132133

133-
expect(mockTx.brand.create).toHaveBeenCalledWith({
134-
data: expect.objectContaining({
135-
slug: 'will-example',
136-
orgSlug: 'will-example',
134+
expect(mockTx.brand.upsert).toHaveBeenCalledWith(
135+
expect.objectContaining({
136+
where: { slug: 'will-example' },
137+
create: expect.objectContaining({
138+
slug: 'will-example',
139+
orgSlug: 'will-example',
140+
}),
137141
}),
138-
});
142+
);
139143

140144
expect(mockTx.apiKey.create).toHaveBeenCalledWith({
141145
data: expect.objectContaining({

backend/src/auth/cli-auth.service.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ describe('CliAuthService', () => {
1919
organization: {
2020
findUnique: jest.fn(),
2121
},
22+
brand: {
23+
findFirst: jest.fn().mockResolvedValue({ domain: 'alpha-org.graspful.ai' }),
24+
},
2225
};
2326

2427
mockApiKeyService = {
@@ -128,6 +131,7 @@ describe('CliAuthService', () => {
128131
apiKey: 'gsk_cli_key',
129132
orgSlug: 'alpha-org',
130133
userId: 'user-1',
134+
brandDomain: 'alpha-org.graspful.ai',
131135
});
132136
});
133137

@@ -209,6 +213,7 @@ describe('CliAuthService', () => {
209213
apiKey: 'gsk_cli_key',
210214
orgSlug: 'alpha-org',
211215
userId: 'user-1',
216+
brandDomain: 'alpha-org.graspful.ai',
212217
});
213218
await expect(service.exchange('token-once')).resolves.toEqual({ status: 'expired' });
214219
});

backend/src/auth/cli-auth.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,19 @@ export class CliAuthService {
120120
return { status: 'expired' as const };
121121
}
122122

123+
// Look up the default brand domain for this org
124+
const brand = await this.prisma.brand.findFirst({
125+
where: { orgSlug: org.slug, isActive: true },
126+
select: { domain: true },
127+
orderBy: { createdAt: 'asc' },
128+
});
129+
123130
return {
124131
status: 'complete' as const,
125132
apiKey: this.decrypt(session.encryptedApiKey),
126133
orgSlug: org.slug,
127134
userId: session.userId,
135+
brandDomain: brand?.domain ?? `${org.slug}.graspful.ai`,
128136
};
129137
}
130138

backend/src/auth/provision.service.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,41 @@ export class ProvisionService {
5858
data: { orgId: org.id, userId, role: 'owner' },
5959
});
6060

61-
// Default brand so the org is accessible via the web UI
61+
// Default brand so the org is accessible via the web UI.
62+
// Uses upsert for idempotency in case a brand with this slug already exists.
6263
const domain = `${orgSlug}.graspful.ai`;
63-
await tx.brand.create({
64-
data: {
64+
await tx.brand.upsert({
65+
where: { slug: orgSlug },
66+
update: {
67+
name: orgName,
68+
domain,
69+
tagline: 'Adaptive learning',
70+
logoUrl: '/icon.svg',
71+
orgSlug,
72+
theme: { preset: 'indigo', radius: '0.5rem' },
73+
landing: {
74+
hero: { headline: orgName, subheadline: 'Adaptive learning', ctaText: 'Start Learning' },
75+
features: { heading: 'Features', items: [] },
76+
howItWorks: { heading: 'How it works', items: [] },
77+
faq: [],
78+
},
79+
seo: { title: orgName, description: 'Adaptive learning', keywords: [] },
80+
},
81+
create: {
6582
slug: orgSlug,
6683
name: orgName,
6784
domain,
68-
tagline: 'Learn adaptively',
85+
tagline: 'Adaptive learning',
6986
logoUrl: '/icon.svg',
7087
orgSlug,
71-
theme: {},
88+
theme: { preset: 'indigo', radius: '0.5rem' },
7289
landing: {
7390
hero: { headline: orgName, subheadline: 'Adaptive learning', ctaText: 'Start Learning' },
7491
features: { heading: 'Features', items: [] },
7592
howItWorks: { heading: 'How it works', items: [] },
7693
faq: [],
7794
},
78-
seo: { title: orgName, description: `Adaptive learning at ${orgName}`, keywords: [] },
95+
seo: { title: orgName, description: 'Adaptive learning', keywords: [] },
7996
},
8097
});
8198

backend/src/auth/registration.service.ts

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class RegistrationService {
2323
);
2424
}
2525

26-
async register(email: string, password: string): Promise<{ userId: string; orgSlug: string; apiKey: string }> {
26+
async register(email: string, password: string): Promise<{ userId: string; orgSlug: string; apiKey: string; brandDomain: string }> {
2727
// 1. Create Supabase user
2828
const { data: authData, error: authError } =
2929
await this.supabase.auth.admin.createUser({
@@ -79,38 +79,40 @@ export class RegistrationService {
7979

8080
// Create a default brand so the org is accessible via the web UI.
8181
// This is a placeholder — it gets replaced when the user imports a brand YAML.
82+
// Uses upsert for idempotency in case a brand with this slug already exists.
8283
const domain = `${orgSlug}.graspful.ai`;
83-
await tx.brand.create({
84-
data: {
84+
await tx.brand.upsert({
85+
where: { slug: orgSlug },
86+
update: {
87+
name: orgName,
88+
domain,
89+
tagline: 'Adaptive learning',
90+
logoUrl: '/icon.svg',
91+
orgSlug,
92+
theme: { preset: 'indigo', radius: '0.5rem' },
93+
landing: {
94+
hero: { headline: orgName, subheadline: 'Adaptive learning', ctaText: 'Start Learning' },
95+
features: { heading: 'Features', items: [] },
96+
howItWorks: { heading: 'How it works', items: [] },
97+
faq: [],
98+
},
99+
seo: { title: orgName, description: 'Adaptive learning', keywords: [] },
100+
},
101+
create: {
85102
slug: orgSlug,
86103
name: orgName,
87104
domain,
88-
tagline: 'Learn adaptively',
105+
tagline: 'Adaptive learning',
89106
logoUrl: '/icon.svg',
90107
orgSlug,
91-
theme: {},
108+
theme: { preset: 'indigo', radius: '0.5rem' },
92109
landing: {
93110
hero: { headline: orgName, subheadline: 'Adaptive learning', ctaText: 'Start Learning' },
94-
features: {
95-
heading: 'Why choose us?',
96-
items: [
97-
{ title: 'Adaptive Learning', description: 'Content adapts to your knowledge level', icon: 'Brain' },
98-
{ title: 'Spaced Repetition', description: 'Review at optimal intervals for lasting memory', icon: 'Timer' },
99-
{ title: 'Progress Tracking', description: 'See exactly where you stand', icon: 'Workflow' },
100-
],
101-
},
102-
howItWorks: {
103-
heading: 'How it works',
104-
items: [
105-
{ title: 'Take a diagnostic', description: 'We assess what you already know' },
106-
{ title: 'Learn adaptively', description: 'Focus on gaps, skip what you know' },
107-
{ title: 'Master the material', description: 'Prove mastery through progressive challenges' },
108-
],
109-
},
111+
features: { heading: 'Features', items: [] },
112+
howItWorks: { heading: 'How it works', items: [] },
110113
faq: [],
111-
bottomCta: { headline: 'Ready to start learning?', subheadline: 'Begin your adaptive learning journey today.' },
112114
},
113-
seo: { title: orgName, description: `Adaptive learning at ${orgName}`, keywords: [] },
115+
seo: { title: orgName, description: 'Adaptive learning', keywords: [] },
114116
},
115117
});
116118

@@ -132,7 +134,7 @@ export class RegistrationService {
132134
this.logger.warn(`Failed to provision domain ${txResult.domain} on Vercel: ${err}`);
133135
}
134136

135-
return { userId: txResult.userId, orgSlug: txResult.orgSlug, apiKey: txResult.apiKey };
137+
return { userId: txResult.userId, orgSlug: txResult.orgSlug, apiKey: txResult.apiKey, brandDomain: txResult.domain };
136138
} catch (error) {
137139
this.logger.error('Prisma transaction failed during registration', {
138140
message: (error as Error).message,

backend/src/brands/brands.controller.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,15 @@ export class BrandsController {
7474
};
7575
} catch (error) {
7676
this.logger.warn(
77-
`Domain provisioning failed for ${normalizedDomain}, brand created without domain: ${error}`,
77+
`Domain provisioning failed for ${normalizedDomain}: ${error}`,
7878
);
79-
const dnsInstructions =
80-
await this.vercelDomainsService.getDnsInstructions(normalizedDomain);
79+
let dnsInstructions: { type: string; name: string; value: string } | null = null;
80+
try {
81+
dnsInstructions =
82+
await this.vercelDomainsService.getDnsInstructions(normalizedDomain);
83+
} catch {
84+
// DNS lookup also failed — return empty instructions
85+
}
8186
return {
8287
brand,
8388
domain: {

packages/cli/src/commands/register.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,18 @@ export function registerRegisterCommand(program: Command) {
2828
});
2929

3030
cliCapture('cli registered', { method: 'browser-auth' });
31+
const brandDomain = result.brandDomain ?? `${result.orgSlug}.graspful.ai`;
3132
output(
3233
{
3334
userId: result.userId,
3435
orgSlug: result.orgSlug,
3536
apiKey: result.apiKey,
37+
brandDomain,
3638
baseUrl,
3739
},
3840
[
3941
`Created org: ${result.orgSlug}`,
42+
`Brand: ${brandDomain}`,
4043
`API key: ${result.apiKey} (saved to ~/.graspful/credentials.json)`,
4144
'',
4245
`You're ready. Run: graspful import course.yaml --org ${result.orgSlug}`,

packages/cli/src/lib/browser-auth.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface ExchangeCompleteResponse {
2222
apiKey: string;
2323
orgSlug: string;
2424
userId: string;
25+
brandDomain?: string;
2526
}
2627

2728
type ExchangeBrowserAuthResponse =
@@ -45,6 +46,7 @@ export interface BrowserAuthResult {
4546
orgSlug: string;
4647
userId: string;
4748
baseUrl: string;
49+
brandDomain?: string;
4850
}
4951

5052
export function deriveWebUrl(apiUrl: string): string {
@@ -160,6 +162,7 @@ export async function runBrowserAuthFlow(options: BrowserAuthOptions): Promise<B
160162
orgSlug: exchangeData.orgSlug,
161163
userId: exchangeData.userId,
162164
baseUrl,
165+
brandDomain: exchangeData.brandDomain,
163166
};
164167
}
165168

0 commit comments

Comments
 (0)