Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
bun-version: 1.3.9

- name: Install dependencies
run: bun install
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { HumanInputsModule } from './human-inputs/human-inputs.module';
import { McpServersModule } from './mcp-servers/mcp-servers.module';
import { McpGroupsModule } from './mcp-groups/mcp-groups.module';
import { TemplatesModule } from './templates/templates.module';
import { UserPreferencesModule } from './user-preferences/user-preferences.module';

const coreModules = [
AgentsModule,
Expand All @@ -56,6 +57,7 @@ const coreModules = [
StudioMcpModule,
TemplatesModule,
AuditModule,
UserPreferencesModule,
];

const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule];
Expand Down
1 change: 1 addition & 0 deletions backend/src/database/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export * from './mcp-servers';
export * from './node-io';
export * from './organization-settings';
export * from './templates';
export * from './user-preferences';
20 changes: 20 additions & 0 deletions backend/src/database/schema/user-preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { boolean, index, pgTable, primaryKey, timestamp, varchar } from 'drizzle-orm/pg-core';

export const userPreferencesTable = pgTable(
'user_preferences',
{
userId: varchar('user_id', { length: 191 }).notNull(),
organizationId: varchar('organization_id', { length: 191 }).notNull(),
hasCompletedOnboarding: boolean('has_completed_onboarding').notNull().default(false),
hasCompletedBuilderTour: boolean('has_completed_builder_tour').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.organizationId] }),
orgIdx: index('user_preferences_org_idx').on(table.organizationId),
}),
);

export type UserPreferences = typeof userPreferencesTable.$inferSelect;
export type NewUserPreferences = typeof userPreferencesTable.$inferInsert;
30 changes: 30 additions & 0 deletions backend/src/user-preferences/user-preferences.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';

import { UserPreferencesService } from './user-preferences.service';
import { UpdateUserPreferencesDto, UserPreferencesResponseDto } from './user-preferences.dto';
import { CurrentAuth } from '../auth/auth-context.decorator';
import type { AuthContext } from '../auth/types';

@ApiTags('user-preferences')
@Controller('users/me/preferences')
export class UserPreferencesController {
constructor(private readonly service: UserPreferencesService) {}

@Get()
@ApiOkResponse({ type: UserPreferencesResponseDto })
async getPreferences(
@CurrentAuth() auth: AuthContext | null,
): Promise<UserPreferencesResponseDto> {
return this.service.getPreferences(auth);
}

@Patch()
@ApiOkResponse({ type: UserPreferencesResponseDto })
async updatePreferences(
@CurrentAuth() auth: AuthContext | null,
@Body() body: UpdateUserPreferencesDto,
): Promise<UserPreferencesResponseDto> {
return this.service.updatePreferences(auth, body);
}
}
22 changes: 22 additions & 0 deletions backend/src/user-preferences/user-preferences.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';

export class UpdateUserPreferencesDto {
@ApiPropertyOptional({ description: 'Whether the user has completed the onboarding tour' })
@IsOptional()
@IsBoolean()
hasCompletedOnboarding?: boolean;

@ApiPropertyOptional({ description: 'Whether the user has completed the workflow builder tour' })
@IsOptional()
@IsBoolean()
hasCompletedBuilderTour?: boolean;
}

export class UserPreferencesResponseDto {
@ApiProperty()
hasCompletedOnboarding!: boolean;

@ApiProperty()
hasCompletedBuilderTour!: boolean;
}
14 changes: 14 additions & 0 deletions backend/src/user-preferences/user-preferences.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';

import { DatabaseModule } from '../database/database.module';
import { UserPreferencesController } from './user-preferences.controller';
import { UserPreferencesRepository } from './user-preferences.repository';
import { UserPreferencesService } from './user-preferences.service';

@Module({
imports: [DatabaseModule],
controllers: [UserPreferencesController],
providers: [UserPreferencesService, UserPreferencesRepository],
exports: [UserPreferencesService],
})
export class UserPreferencesModule {}
73 changes: 73 additions & 0 deletions backend/src/user-preferences/user-preferences.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Inject, Injectable } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { and, eq, sql } from 'drizzle-orm';

import { DRIZZLE_TOKEN } from '../database/database.module';
import { userPreferencesTable } from '../database/schema';

export interface UserPreferencesData {
hasCompletedOnboarding: boolean;
hasCompletedBuilderTour: boolean;
}

@Injectable()
export class UserPreferencesRepository {
constructor(
@Inject(DRIZZLE_TOKEN)
private readonly db: NodePgDatabase,
) {}

async findByUserAndOrg(
userId: string,
organizationId: string,
): Promise<UserPreferencesData | null> {
const rows = await this.db
.select({
hasCompletedOnboarding: userPreferencesTable.hasCompletedOnboarding,
hasCompletedBuilderTour: userPreferencesTable.hasCompletedBuilderTour,
})
.from(userPreferencesTable)
.where(
and(
eq(userPreferencesTable.userId, userId),
eq(userPreferencesTable.organizationId, organizationId),
),
)
.limit(1);

return rows[0] ?? null;
}

async upsert(
userId: string,
organizationId: string,
updates: Partial<UserPreferencesData>,
): Promise<UserPreferencesData> {
const [result] = await this.db
.insert(userPreferencesTable)
.values({
userId,
organizationId,
hasCompletedOnboarding: updates.hasCompletedOnboarding ?? false,
hasCompletedBuilderTour: updates.hasCompletedBuilderTour ?? false,
})
.onConflictDoUpdate({
target: [userPreferencesTable.userId, userPreferencesTable.organizationId],
set: {
...(updates.hasCompletedOnboarding !== undefined
? { hasCompletedOnboarding: updates.hasCompletedOnboarding }
: {}),
...(updates.hasCompletedBuilderTour !== undefined
? { hasCompletedBuilderTour: updates.hasCompletedBuilderTour }
: {}),
updatedAt: sql`now()`,
},
})
.returning({
hasCompletedOnboarding: userPreferencesTable.hasCompletedOnboarding,
hasCompletedBuilderTour: userPreferencesTable.hasCompletedBuilderTour,
});

return result;
}
}
33 changes: 33 additions & 0 deletions backend/src/user-preferences/user-preferences.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Injectable, BadRequestException } from '@nestjs/common';

import { UserPreferencesRepository, type UserPreferencesData } from './user-preferences.repository';
import type { AuthContext } from '../auth/types';
import { DEFAULT_ORGANIZATION_ID } from '../auth/constants';

@Injectable()
export class UserPreferencesService {
constructor(private readonly repository: UserPreferencesRepository) {}

private resolveIds(auth: AuthContext | null): { userId: string; organizationId: string } {
const userId = auth?.userId;
if (!userId) {
throw new BadRequestException('User context is required');
}
const organizationId = auth?.organizationId ?? DEFAULT_ORGANIZATION_ID;
return { userId, organizationId };
}

async getPreferences(auth: AuthContext | null): Promise<UserPreferencesData> {
const { userId, organizationId } = this.resolveIds(auth);
const prefs = await this.repository.findByUserAndOrg(userId, organizationId);
return prefs ?? { hasCompletedOnboarding: false, hasCompletedBuilderTour: false };
}

async updatePreferences(
auth: AuthContext | null,
updates: Partial<UserPreferencesData>,
): Promise<UserPreferencesData> {
const { userId, organizationId } = this.resolveIds(auth);
return this.repository.upsert(userId, organizationId, updates);
}
}
89 changes: 88 additions & 1 deletion frontend/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ import { setMobilePlacementSidebarClose } from '@/components/layout/sidebar-stat
import { useCommandPaletteStore } from '@/store/commandPaletteStore';
import { usePrefetchOnIdle } from '@/hooks/usePrefetchOnIdle';
import { prefetchIdleRoutes, prefetchRoute } from '@/lib/prefetch-routes';
import { OnboardingDialog } from '@/components/onboarding/OnboardingDialog';
import { useOnboardingStore } from '@/store/onboardingStore';
import {
useUserPreferences,
useUpdateUserPreferences,
} from '@/hooks/queries/useUserPreferencesQueries';

interface AppLayoutProps {
children: React.ReactNode;
Expand Down Expand Up @@ -88,6 +94,58 @@ export function AppLayout({ children }: AppLayoutProps) {
const { theme, startTransition } = useThemeStore();
const openCommandPalette = useCommandPaletteStore((state) => state.open);

// Onboarding tutorial state (persisted in DB via API)
const {
data: userPreferences,
isLoading: prefsLoading,
isSuccess: prefsLoaded,
} = useUserPreferences();
const updatePreferences = useUpdateUserPreferences();
// While loading: hide dialog (true). After loaded: use server value, default false for new users.
const hasCompletedOnboarding = prefsLoaded
? (userPreferences?.hasCompletedOnboarding ?? false)
: true;
const onboardingStep = useOnboardingStore((state) => state.currentStep);
const setOnboardingStep = useOnboardingStore((state) => state.setCurrentStep);
const completeOnboarding = () => updatePreferences.mutate({ hasCompletedOnboarding: true });

// One-time migration: localStorage -> DB
useEffect(() => {
if (prefsLoading || !isAuthenticated) return;

const STORAGE_KEY = 'shipsec-onboarding';
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;

try {
const stored = JSON.parse(raw);
const localState = stored?.state;
if (!localState) {
localStorage.removeItem(STORAGE_KEY);
return;
}

const updates: { hasCompletedOnboarding?: boolean; hasCompletedBuilderTour?: boolean } = {};

if (localState.hasCompletedOnboarding && !userPreferences?.hasCompletedOnboarding) {
updates.hasCompletedOnboarding = true;
}
if (localState.hasCompletedBuilderTour && !userPreferences?.hasCompletedBuilderTour) {
updates.hasCompletedBuilderTour = true;
}

if (Object.keys(updates).length > 0) {
updatePreferences.mutate(updates, {
onSuccess: () => localStorage.removeItem(STORAGE_KEY),
});
} else {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}, [prefsLoading, isAuthenticated]);

// Get git SHA for version display (monorepo - same for frontend and backend)
const gitSha = env.VITE_GIT_SHA;
// If it's a tag (starts with v), show full tag. Otherwise show first 7 chars of SHA
Expand All @@ -100,7 +158,13 @@ export function AppLayout({ children }: AppLayoutProps) {

// Auto-collapse sidebar when opening workflow builder, expand for other routes
// On mobile, always start collapsed
// During onboarding, force sidebar open so highlighted elements are visible
useEffect(() => {
if (isAuthenticated && !hasCompletedOnboarding) {
setSidebarOpen(true);
setWasExplicitlyOpened(true);
return;
}
if (isMobile) {
setSidebarOpen(false);
setWasExplicitlyOpened(false);
Expand All @@ -112,7 +176,14 @@ export function AppLayout({ children }: AppLayoutProps) {
setSidebarOpen(!isWorkflowRoute);
setWasExplicitlyOpened(!isWorkflowRoute);
}
}, [location.pathname, isMobile]);
}, [location.pathname, isMobile, isAuthenticated, hasCompletedOnboarding]);

// Expand Manage section when onboarding highlights it
useEffect(() => {
if (isAuthenticated && !hasCompletedOnboarding && onboardingStep === 5) {
setSettingsOpen(true);
}
}, [isAuthenticated, hasCompletedOnboarding, onboardingStep]);

// Close sidebar on mobile when navigating
useEffect(() => {
Expand Down Expand Up @@ -268,6 +339,14 @@ export function AppLayout({ children }: AppLayoutProps) {
toggle: handleToggle,
};

// Onboarding target IDs for spotlight tour
const onboardingTargetIds: Record<string, string> = {
'/': 'workflow-builder',
'/templates': 'template-library',
'/schedules': 'schedules',
'/action-center': 'action-center',
};

const navigationItems = [
{
name: 'Workflow Builder',
Expand Down Expand Up @@ -382,6 +461,12 @@ export function AppLayout({ children }: AppLayoutProps) {
return (
<SidebarContext.Provider value={sidebarContextValue}>
<ThemeTransition />
<OnboardingDialog
open={isAuthenticated && !prefsLoading && !hasCompletedOnboarding}
onComplete={completeOnboarding}
currentStep={onboardingStep}
onStepChange={setOnboardingStep}
/>
<div className="flex h-screen bg-background overflow-hidden">
{/* Mobile backdrop overlay */}
{isMobile && sidebarOpen && (
Expand Down Expand Up @@ -493,6 +578,7 @@ export function AppLayout({ children }: AppLayoutProps) {
<Link
key={item.href}
to={item.href}
data-onboarding={onboardingTargetIds[item.href]}
onMouseEnter={() => prefetchRoute(item.href)}
onClick={(e) => {
// If modifier key is held (CMD+click, Ctrl+click), link opens in new tab
Expand Down Expand Up @@ -541,6 +627,7 @@ export function AppLayout({ children }: AppLayoutProps) {
{/* Manage Collapsible Section */}
<div className="px-2 mt-2">
<button
data-onboarding="manage-section"
onClick={() => setSettingsOpen(!settingsOpen)}
className={cn(
'w-full flex items-center gap-3 py-2 rounded-lg transition-colors',
Expand Down
Loading
Loading