From 9e3b4955b92306a1707b2a9a08fe4010a6a63a01 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 09:58:41 +0200 Subject: [PATCH] feat(api): add integrations onboarding step with dashboard population Add "integrations" step to onboarding flow between settings and finish. Add setupIntegrations mutation that reads existing integrations from DB, populates the dashboard with matching widgets (respecting config-driven sizes/options/skip), places app widgets for each app, and creates a bookmarks widget with seeded default apps. --- .../api/src/router/onboard/onboard-queries.ts | 3 + .../api/src/router/onboard/onboard-router.ts | 154 +++++++++++++++++- packages/definitions/src/onboarding.ts | 2 +- 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts index 15f205d8b..242d861e4 100644 --- a/packages/api/src/router/onboard/onboard-queries.ts +++ b/packages/api/src/router/onboard/onboard-queries.ts @@ -76,6 +76,9 @@ const nextSteps: Partial [config.kind, config])); + +const getWidgetConfig = (kind: WidgetKind) => widgetConfigMap.get(kind); + export const onboardRouter = createTRPCRouter({ currentStep: publicProcedure.query(async ({ ctx }) => { return await getOnboardingOrFallbackAsync(ctx.db); @@ -14,7 +29,6 @@ export const onboardRouter = createTRPCRouter({ nextStep: publicProcedure .input( z.object({ - // Preferred step is only needed for 'preferred' conditions preferredStep: zodEnumFromArray(onboardingSteps).optional(), }), ) @@ -33,4 +47,136 @@ export const onboardRouter = createTRPCRouter({ step: "start", }); }), + setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => { + const db = ctx.db; + + const allIntegrations = await db.query.integrations.findMany(); + const allApps = await db.query.apps.findMany(); + + const board = await db.query.boards.findFirst({ + with: { + sections: true, + layouts: true, + }, + }); + + if (board) { + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { + await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); + } + + const section = board.sections.find((boardSection) => boardSection.kind === "empty"); + const layout = board.layouts[0]; + + if (section && layout) { + const columnCount = layout.columnCount; + let xOffset = 0; + let yOffset = 0; + let rowMaxHeight = 0; + + const placeWidgetAsync = async ( + kind: WidgetKind, + linkedIntegrationIds: string[], + options?: string, + size?: { width: number; height: number }, + ) => { + const itemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; + + if (xOffset + itemWidth > columnCount) { + xOffset = 0; + yOffset += rowMaxHeight; + rowMaxHeight = 0; + } + + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId: board.id, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await db.insert(itemLayouts).values({ + itemId, + sectionId: section.id, + layoutId: layout.id, + xOffset, + yOffset, + width: itemWidth, + height: itemHeight, + }); + + for (const integrationId of linkedIntegrationIds) { + await db.insert(integrationItems).values({ + itemId, + integrationId, + }); + } + + rowMaxHeight = Math.max(rowMaxHeight, itemHeight); + xOffset += itemWidth; + }; + + const placedWidgets = new Set(); + + // Place integration-specific widgets + for (const integration of allIntegrations) { + const kind = integration.kind; + + for (const widgetKind of getWidgetKindsForIntegration(kind)) { + if (placedWidgets.has(widgetKind)) continue; + const config = getWidgetConfig(widgetKind); + if (config?.skip) continue; + placedWidgets.add(widgetKind); + + const matchingIds = allIntegrations + .filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind)) + .map((row) => row.id); + + const options = config?.options ? superjson.stringify(config.options) : undefined; + await placeWidgetAsync( + widgetKind, + matchingIds, + options, + config && { width: config.width, height: config.height }, + ); + } + } + + // Place an app widget for each app in the DB + for (const app of allApps) { + const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }); + await placeWidgetAsync("app", [], appOptions, { width: 1, height: 1 }); + } + + // Place general widgets from config + const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); + const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id); + + for (const config of defaultWidgetConfigs) { + if (config.skip) continue; + if (placedWidgets.has(config.kind)) continue; + placedWidgets.add(config.kind); + + let options = config.options ? { ...config.options } : undefined; + + if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) { + options = { ...options, items: bookmarkAppIds }; + } + + await placeWidgetAsync(config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); + } + } + } + + await nextOnboardingStepAsync(db, undefined); + }), }); diff --git a/packages/definitions/src/onboarding.ts b/packages/definitions/src/onboarding.ts index d9a6ea03e..305a71f65 100644 --- a/packages/definitions/src/onboarding.ts +++ b/packages/definitions/src/onboarding.ts @@ -1,2 +1,2 @@ -export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const; +export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const; export type OnboardingStep = (typeof onboardingSteps)[number];