Skip to content

Commit b224e1c

Browse files
willwearingclaude
andcommitted
fix: humanize brand defaults, make brand setup required in agent workflow
- Registration/provision now uses title-cased org name for brand ("Will Use Case Selling Posthog" not "will use case selling posthog") - Brand headline uses "Welcome to {orgName}" not raw slug - CLAUDE.md: brand creation changed from optional to required step - MCP tool table marks graspful_create_brand and graspful_import_brand as required - E2E test: new step verifies brand name/headline are not raw slug garbage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 131bfd5 commit b224e1c

5 files changed

Lines changed: 104 additions & 68 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ If MCP is already configured, you have these tools available — no CLI needed:
4545
| `graspful_validate` | No | Validate YAML against schema |
4646
| `graspful_review_course` | No | Run 10 quality checks |
4747
| `graspful_describe_course` | No | Course statistics |
48-
| `graspful_create_brand` | No | Generate brand YAML |
48+
| `graspful_create_brand` | No | Generate brand YAML (required — every org needs a brand) |
4949
| `graspful_import_course` | **Yes** | Import course to platform (set `GRASPFUL_API_KEY` first) |
5050
| `graspful_publish_course` | **Yes** | Publish a draft course (set `GRASPFUL_API_KEY` first) |
51-
| `graspful_import_brand` | **Yes** | Import brand config (set `GRASPFUL_API_KEY` first) |
51+
| `graspful_import_brand` | **Yes** | Import brand config — required for site to work (set `GRASPFUL_API_KEY` first) |
5252
| `graspful_list_courses` | **Yes** | List org courses (set `GRASPFUL_API_KEY` first) |
5353

5454
Tools marked "No" for auth work offline — no account needed. Tools marked **Yes** will fail with a clear error if you haven't authenticated. Run `graspful register`, then restart MCP with `GRASPFUL_API_KEY`.
@@ -160,7 +160,17 @@ When building a course from a PDF or document:
160160
4. For visual content (photos, diagrams, comparisons), find or request publicly accessible image URLs and use `image` content blocks
161161
5. Do not copy-paste prose verbatim — rewrite for the lesson pattern (instruction -> worked example -> problems)
162162

163-
### Step 4: Brand (optional)
163+
### Step 4: Create or update the brand
164+
165+
Every org needs a brand for the site to work. Registration creates a minimal default,
166+
but you should update it with content relevant to the course topic.
167+
168+
Use `graspful_create_brand` to generate a brand YAML tailored to the course topic,
169+
then import it with `graspful_import_brand`. This updates the landing page headline,
170+
features, and SEO to match the actual course content.
171+
172+
If the org already has a brand (from registration), importing a new one updates it
173+
in place (upsert by slug).
164174

165175
Create a white-label landing page and theme:
166176

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,44 @@ test.describe.serial(
357357
expect(body.orgSlug).toBe(creatorOrgSlug);
358358
});
359359

360+
// ── Step 2b: Brand quality — not placeholder garbage ────────────
361+
test("step 2b: brand landing content is not placeholder garbage", async ({
362+
request,
363+
}) => {
364+
const domain = `${creatorOrgSlug}.graspful.ai`;
365+
366+
const res = await request.get(
367+
`${BACKEND_URL}/brands/by-domain/${domain}`,
368+
{ headers: { "Content-Type": "application/json" } }
369+
);
370+
371+
if (res.status() === 404) {
372+
// Brand not yet created — skip (will be verified after import)
373+
return;
374+
}
375+
376+
expect(res.status()).toBe(200);
377+
378+
const brand = await res.json();
379+
380+
// Brand name should NOT equal the raw slug
381+
expect(brand.name).not.toBe(creatorOrgSlug);
382+
383+
// Brand name should be title-cased (not lowercase with spaces)
384+
expect(brand.name).not.toBe(creatorOrgSlug.replace(/-/g, ' '));
385+
386+
// Landing hero headline should NOT be the raw slug
387+
const headline = brand.landing?.hero?.headline;
388+
expect(headline).toBeTruthy();
389+
expect(headline).not.toBe(creatorOrgSlug);
390+
expect(headline).not.toBe(creatorOrgSlug.replace(/-/g, ' '));
391+
expect(headline.length).toBeGreaterThanOrEqual(10);
392+
393+
// Brand tagline should be at least 10 characters
394+
expect(brand.tagline).toBeTruthy();
395+
expect(brand.tagline.length).toBeGreaterThanOrEqual(10);
396+
});
397+
360398
// ── Step 3: Scaffold — verify skeleton structure ───────────────
361399
test("step 3: scaffold produces valid skeleton structure", async () => {
362400
// We construct the YAML directly (most reliable).

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ describe('RegistrationService', () => {
118118
expect(mockTx.organization.create).toHaveBeenCalledWith({
119119
data: {
120120
slug: 'will-example',
121-
name: 'will example',
121+
name: 'Will Example',
122122
niche: 'general',
123123
},
124124
});

backend/src/auth/provision.service.ts

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
22
import { PrismaService } from '@/prisma/prisma.service';
33
import { VercelDomainsService } from '@/shared/application/vercel-domains.service';
44

5+
/**
6+
* Convert a slug like "will-use-case-selling-posthog" to "Will Use Case Selling Posthog".
7+
* Used as a fallback when the org name would otherwise be the raw slug with spaces.
8+
*/
9+
function humanizeSlug(slug: string): string {
10+
return slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
11+
}
12+
513
/**
614
* Ensures every authenticated user has a personal organization and brand.
715
* Called by POST /auth/provision after Supabase Auth sign-up
@@ -47,7 +55,7 @@ export class ProvisionService {
4755
const clash = await this.prisma.organization.findUnique({ where: { slug: orgSlug } });
4856
if (clash) orgSlug = `${orgSlug}-${Date.now().toString(36).slice(-4)}`;
4957

50-
const orgName = orgSlug.replace(/-/g, ' ');
58+
const orgName = humanizeSlug(orgSlug);
5159

5260
const result = await this.prisma.$transaction(async (tx) => {
5361
const org = await tx.organization.create({
@@ -61,39 +69,25 @@ export class ProvisionService {
6169
// Default brand so the org is accessible via the web UI.
6270
// Uses upsert for idempotency in case a brand with this slug already exists.
6371
const domain = `${orgSlug}.graspful.ai`;
72+
const brandData = {
73+
name: orgName,
74+
domain,
75+
tagline: `Adaptive learning by ${orgName}`,
76+
logoUrl: '/icon.svg',
77+
orgSlug,
78+
theme: { preset: 'indigo', radius: '0.5rem' },
79+
landing: {
80+
hero: { headline: `Welcome to ${orgName}`, subheadline: 'Adaptive learning that meets you where you are', ctaText: 'Start Learning' },
81+
features: { heading: 'Features', items: [] },
82+
howItWorks: { heading: 'How it works', items: [] },
83+
faq: [],
84+
},
85+
seo: { title: orgName, description: `Adaptive learning by ${orgName}`, keywords: [] },
86+
};
6487
await tx.brand.upsert({
6588
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: {
82-
slug: orgSlug,
83-
name: orgName,
84-
domain,
85-
tagline: 'Adaptive learning',
86-
logoUrl: '/icon.svg',
87-
orgSlug,
88-
theme: { preset: 'indigo', radius: '0.5rem' },
89-
landing: {
90-
hero: { headline: orgName, subheadline: 'Adaptive learning', ctaText: 'Start Learning' },
91-
features: { heading: 'Features', items: [] },
92-
howItWorks: { heading: 'How it works', items: [] },
93-
faq: [],
94-
},
95-
seo: { title: orgName, description: 'Adaptive learning', keywords: [] },
96-
},
89+
update: brandData,
90+
create: { slug: orgSlug, ...brandData },
9791
});
9892

9993
return { orgSlug: org.slug, orgId: org.id };

backend/src/auth/registration.service.ts

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { VercelDomainsService } from '@/shared/application/vercel-domains.servic
66
import { ApiKeyService } from './api-key/api-key.service';
77
import * as crypto from 'crypto';
88

9+
/**
10+
* Convert a slug like "will-use-case-selling-posthog" to "Will Use Case Selling Posthog".
11+
* Used as a fallback when the org name would otherwise be the raw slug with spaces.
12+
*/
13+
function humanizeSlug(slug: string): string {
14+
return slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
15+
}
16+
917
@Injectable()
1018
export class RegistrationService {
1119
private readonly logger = new Logger(RegistrationService.name);
@@ -62,7 +70,7 @@ export class RegistrationService {
6270
let orgSlug = this.emailToOrgSlug(email);
6371
const existing = await this.prisma.organization.findUnique({ where: { slug: orgSlug } });
6472
if (existing) orgSlug = `${orgSlug}-${Date.now().toString(36).slice(-4)}`;
65-
const orgName = orgSlug.replace(/-/g, ' ');
73+
const orgName = humanizeSlug(orgSlug);
6674

6775
// 3. Create DB records
6876
// Note: Supabase has an AFTER INSERT trigger on auth.users that auto-creates
@@ -81,39 +89,25 @@ export class RegistrationService {
8189
// This is a placeholder — it gets replaced when the user imports a brand YAML.
8290
// Uses upsert for idempotency in case a brand with this slug already exists.
8391
const domain = `${orgSlug}.graspful.ai`;
92+
const brandData = {
93+
name: orgName,
94+
domain,
95+
tagline: `Adaptive learning by ${orgName}`,
96+
logoUrl: '/icon.svg',
97+
orgSlug,
98+
theme: { preset: 'indigo', radius: '0.5rem' },
99+
landing: {
100+
hero: { headline: `Welcome to ${orgName}`, subheadline: 'Adaptive learning that meets you where you are', ctaText: 'Start Learning' },
101+
features: { heading: 'Features', items: [] },
102+
howItWorks: { heading: 'How it works', items: [] },
103+
faq: [],
104+
},
105+
seo: { title: orgName, description: `Adaptive learning by ${orgName}`, keywords: [] },
106+
};
84107
await tx.brand.upsert({
85108
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: {
102-
slug: orgSlug,
103-
name: orgName,
104-
domain,
105-
tagline: 'Adaptive learning',
106-
logoUrl: '/icon.svg',
107-
orgSlug,
108-
theme: { preset: 'indigo', radius: '0.5rem' },
109-
landing: {
110-
hero: { headline: orgName, subheadline: 'Adaptive learning', ctaText: 'Start Learning' },
111-
features: { heading: 'Features', items: [] },
112-
howItWorks: { heading: 'How it works', items: [] },
113-
faq: [],
114-
},
115-
seo: { title: orgName, description: 'Adaptive learning', keywords: [] },
116-
},
109+
update: brandData,
110+
create: { slug: orgSlug, ...brandData },
117111
});
118112

119113
// Create API key inside the transaction so it can see the uncommitted org

0 commit comments

Comments
 (0)