From 3a9ae566f899b7fcf17d2f97f66c1228f22a1a3d Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 03:16:34 +0000 Subject: [PATCH] feat(design): a new workspace's portal starts on Home Setup publishes the design a new workspace starts with: its branding converted, as an established workspace's is on read, with Home on and laid out from the pages its goals turned on. It is the first version, from setup, with no author. Only setup does this, and only for a new workspace with the design engine on and no custom CSS a switch would have to carry over. A workspace that already has a design keeps it, and an established workspace's root never changes on upgrade. The setup tests that stand in for the settings helpers now pass the real ones through, since setup reads the portal config to lay Home out. --- .../__tests__/design.service.db.test.ts | 41 +++++++++---- .../server/domains/design/design.service.ts | 27 ++++++++- .../server/domains/design/starting-design.ts | 58 +++++++++++++++++++ .../onboarding-bootstrap-claim.db.test.ts | 3 +- .../__tests__/onboarding-goals.db.test.ts | 34 +++++++++++ .../onboarding-setup-claim-race.db.test.ts | 3 +- apps/web/src/lib/server/onboarding-board.ts | 6 ++ 7 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/lib/server/domains/design/starting-design.ts diff --git a/apps/web/src/lib/server/domains/design/__tests__/design.service.db.test.ts b/apps/web/src/lib/server/domains/design/__tests__/design.service.db.test.ts index dc63a9bc8f..54f00c6df3 100644 --- a/apps/web/src/lib/server/domains/design/__tests__/design.service.db.test.ts +++ b/apps/web/src/lib/server/domains/design/__tests__/design.service.db.test.ts @@ -21,6 +21,7 @@ import { getPublishedDesign, listRevisions, publishDraft, + publishStartingDesign, restoreRevision, REVISIONS_KEPT, saveDraft, @@ -38,16 +39,14 @@ async function admin() { const userId = createId('user') as UserId const id = createId('principal') as PrincipalId await testDb.insert(user).values({ id: userId, name: 'Admin', email: `${userId}@example.com` }) - await testDb - .insert(principal) - .values({ - id, - userId, - role: 'admin', - type: 'user', - displayName: 'Admin', - createdAt: new Date(), - }) + await testDb.insert(principal).values({ + id, + userId, + role: 'admin', + type: 'user', + displayName: 'Admin', + createdAt: new Date(), + }) return { principalId: id, actor: { userId, email: `${userId}@example.com`, role: 'admin', type: 'user' as const }, @@ -60,6 +59,28 @@ const withAccent = (accent: string) => { return doc } +describe('a new workspace’s starting design', () => { + it('publishes it as the first version, with no author', async () => { + const doc = withAccent('#0F766E') + doc.nav.home.on = true + expect(await testDb.transaction((tx) => publishStartingDesign(tx, doc))).toBe(true) + const state = await getDesignState() + expect(state?.published?.number).toBe(1) + expect(state?.published?.document.nav.home.on).toBe(true) + const [row] = await testDb.select().from(portalDesignRevisions) + expect(row).toMatchObject({ source: 'conversion', principalId: null }) + }) + + it('leaves a workspace that already has a design alone', async () => { + const who = await admin() + await saveDraft(withAccent('#0F766E'), who) + await publishDraft({ ...who, source: 'appearance' }) + expect(await testDb.transaction((tx) => publishStartingDesign(tx, defaultDesign()))).toBe(false) + expect((await getPublishedDesign())?.brand.light.accent).toBe('#0f766e') + expect(await listRevisions()).toHaveLength(1) + }) +}) + describe('drafts', () => { it('saves a validated draft and leaves the published design alone', async () => { const who = await admin() diff --git a/apps/web/src/lib/server/domains/design/design.service.ts b/apps/web/src/lib/server/domains/design/design.service.ts index 3000f793f3..fd5b524e69 100644 --- a/apps/web/src/lib/server/domains/design/design.service.ts +++ b/apps/web/src/lib/server/domains/design/design.service.ts @@ -170,7 +170,7 @@ async function publishDocument( tx: Transaction, designId: PortalDesignId, document: PortalDesign, - author: DesignAuthor, + author: Pick | null, source: PortalDesignSource, message: string | null ) { @@ -183,7 +183,14 @@ async function publishDocument( const number = (latest?.number ?? 0) + 1 const [created] = await tx .insert(portalDesignRevisions) - .values({ designId, number, document, source, principalId: author.principalId, message }) + .values({ + designId, + number, + document, + source, + principalId: author?.principalId ?? null, + message, + }) .returning({ id: portalDesignRevisions.id }) await tx .update(portalDesigns) @@ -261,6 +268,22 @@ export async function publishDraftInTransaction( return published } +/** + * Publishes the design a new workspace starts with as its first version, from + * setup rather than a person. A workspace with a design keeps it; the result + * says whether this one was published. + */ +export async function publishStartingDesign( + tx: Transaction, + document: PortalDesign +): Promise { + await tx.insert(portalDesigns).values({}).onConflictDoNothing() + const row = (await designRow(tx, true))! + if (row.publishedRevisionId || row.draft) return false + await publishDocument(tx, row.id, validateDesign(document), null, 'conversion', null) + return true +} + /** Publishes an old revision's document as the next revision. A draft in progress is kept. */ export async function restoreRevision( revisionId: PortalDesignRevisionId, diff --git a/apps/web/src/lib/server/domains/design/starting-design.ts b/apps/web/src/lib/server/domains/design/starting-design.ts new file mode 100644 index 0000000000..facac269cd --- /dev/null +++ b/apps/web/src/lib/server/domains/design/starting-design.ts @@ -0,0 +1,58 @@ +/** + * The design a new workspace starts with: its branding converted, as an + * established workspace's is on read, with Home on and laid out from the + * pages the workspace has. An established workspace's `/` never changes on + * upgrade; only setup publishes this. + */ +import { eq, settings, type Transaction } from '@/lib/server/db' +import { parseJsonOrNull, parsePortalConfig } from '@/lib/server/domains/settings/settings.helpers' +import { resolveStatusSettings } from '@/lib/server/domains/settings/settings.status' +import { + resolveFeatureFlags, + type BrandingConfig, +} from '@/lib/server/domains/settings/settings.types' +import { convertLegacyBranding } from '@/lib/shared/design/convert' +import { starterHomeBlocks } from '@/lib/shared/design/home' +import { portalPageGates } from '@/lib/shared/design/paths' +import type { PortalDesign } from '@/lib/shared/design/schema' +import { publishStartingDesign } from './design.service' +import { advancedCssRemainder, legacyBrandingOf } from './engine' + +type SettingsRow = typeof settings.$inferSelect + +/** + * The starting design for a workspace's settings, or null when it stays on + * its theme: the design engine off, or custom CSS a switch would have to + * carry over. `PORTAL_DESIGN_ENGINE=legacy` still keeps the portal on its + * theme, published design or not. + */ +export function startingDesign(row: SettingsRow): PortalDesign | null { + const featureFlags = resolveFeatureFlags(row.featureFlags) + if (!featureFlags.portalDesignV2) return null + if (advancedCssRemainder(row.customCss ?? '') !== '') return null + const portalConfig = parsePortalConfig(row.portalConfig) + const doc = convertLegacyBranding( + legacyBrandingOf({ + ...row, + brandingConfig: parseJsonOrNull(row.brandingConfig) ?? {}, + portalConfig, + }) + ) + const gates = portalPageGates( + { featureFlags, portalConfig, statusConfig: resolveStatusSettings(row.metadata) }, + false + ) + doc.nav.home.on = true + doc.pages.home.blocks = starterHomeBlocks(gates) + return doc +} + +/** Publishes the starting design for the workspace in `tx`, unless it has a design. */ +export async function seedStartingDesign( + tx: Transaction, + workspaceId: SettingsRow['id'] +): Promise { + const [row] = await tx.select().from(settings).where(eq(settings.id, workspaceId)).limit(1) + const doc = row ? startingDesign(row) : null + return doc ? publishStartingDesign(tx, doc) : false +} diff --git a/apps/web/src/lib/server/functions/__tests__/onboarding-bootstrap-claim.db.test.ts b/apps/web/src/lib/server/functions/__tests__/onboarding-bootstrap-claim.db.test.ts index d3c6aff397..1875a7ef54 100644 --- a/apps/web/src/lib/server/functions/__tests__/onboarding-bootstrap-claim.db.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/onboarding-bootstrap-claim.db.test.ts @@ -68,7 +68,8 @@ vi.mock('@/lib/server/domains/principals/principal.factory', () => ({ ensurePrincipalForUser: hoisted.ensurePrincipalForUser, setPrincipalRole: hoisted.setPrincipalRole, })) -vi.mock('@/lib/server/domains/settings/settings.helpers', () => ({ +vi.mock('@/lib/server/domains/settings/settings.helpers', async (original) => ({ + ...(await original()), invalidateSettingsCache: hoisted.invalidateSettingsCache, })) diff --git a/apps/web/src/lib/server/functions/__tests__/onboarding-goals.db.test.ts b/apps/web/src/lib/server/functions/__tests__/onboarding-goals.db.test.ts index 2701aece9d..faac40bf02 100644 --- a/apps/web/src/lib/server/functions/__tests__/onboarding-goals.db.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/onboarding-goals.db.test.ts @@ -53,6 +53,7 @@ vi.mock('@/lib/server/domains/settings/settings.helpers', async (original) => ({ })) import { saveWorkspaceAndGoalFn, ensureOnboardingHomeReadyFn } from '../onboarding' +const { getPublishedDesign } = await import('@/lib/server/domains/design/design.service') const fixture = await createDbTestFixture({ probe: async (db) => { @@ -383,6 +384,39 @@ describe('wizard goals read and write', () => { expect(getSetupState(row!.setupState)!.ownerPrincipalId).toBe(owner!.id) }) + it('starts a new workspace’s portal on Home, laid out from its goals', async () => { + await saveWorkspaceAndGoalFn({ + data: { workspaceName: 'Acme', goals: ['product_feedback', 'help_center'] }, + }) + const design = await getPublishedDesign() + expect(design?.nav.home.on).toBe(true) + const blocks = design!.pages.home.blocks.map((block) => block.type) + expect(blocks).toEqual(expect.arrayContaining(['composer', 'ideas', 'updates', 'help_topics'])) + expect(blocks).not.toContain('status') + // Saving the goals again keeps the design it has. + await saveWorkspaceAndGoalFn({ data: { workspaceName: 'Acme', goals: ['status_page'] } }) + expect((await getPublishedDesign())?.pages.home.blocks.map((block) => block.type)).toEqual( + blocks + ) + }) + + it('gives no design to an established workspace, or one with the design engine off', async () => { + // An established workspace that turned the design engine on in Labs. + const yearAgo = new Date(Date.now() - 365 * 86_400_000) + await testDb + .insert(settings) + .values(operatorRow(yearAgo, JSON.stringify({ portalDesignV2: true }))) + await ensureOnboardingHomeReadyFn() + expect(await getPublishedDesign()).toBeNull() + + await testDb.delete(settings) + await testDb + .insert(settings) + .values(operatorRow(new Date(), JSON.stringify({ portalDesignV2: false }))) + await ensureOnboardingHomeReadyFn() + expect(await getPublishedDesign()).toBeNull() + }) + it('turns Copilot on Home on for a new workspace an operator provisioned', async () => { await testDb.insert(settings).values(operatorRow(new Date())) await ensureOnboardingHomeReadyFn() diff --git a/apps/web/src/lib/server/functions/__tests__/onboarding-setup-claim-race.db.test.ts b/apps/web/src/lib/server/functions/__tests__/onboarding-setup-claim-race.db.test.ts index bfe971adfc..5616a5aaa5 100644 --- a/apps/web/src/lib/server/functions/__tests__/onboarding-setup-claim-race.db.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/onboarding-setup-claim-race.db.test.ts @@ -47,7 +47,8 @@ vi.mock('@tanstack/react-start', () => ({ const hoisted = vi.hoisted(() => ({ getSession: vi.fn() })) vi.mock('@/lib/server/auth/session', () => ({ getSession: hoisted.getSession })) -vi.mock('@/lib/server/domains/settings/settings.helpers', () => ({ +vi.mock('@/lib/server/domains/settings/settings.helpers', async (original) => ({ + ...(await original()), invalidateSettingsCache: vi.fn(), })) diff --git a/apps/web/src/lib/server/onboarding-board.ts b/apps/web/src/lib/server/onboarding-board.ts index f465daa696..d3e5995444 100644 --- a/apps/web/src/lib/server/onboarding-board.ts +++ b/apps/web/src/lib/server/onboarding-board.ts @@ -150,6 +150,12 @@ export async function applyOnboardingGoals( changesStoredJson((stored[key] as string | null | undefined) ?? null, next) ) } + // A new workspace's portal starts on Home, laid out from the pages its + // goals turned on; an established workspace keeps its root as it is. + if (isNew) { + const { seedStartingDesign } = await import('@/lib/server/domains/design/starting-design') + if (await seedStartingDesign(tx, row.id)) modulesChanged = true + } await prepareOnboardingBoard(tx, { ...state, goals }) await seedGoalPages(tx, row.name, goals) return { modulesChanged }