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 }