Phase 7: Add user onboarding tour and tenant setup wizard
Feature 1 - How-To Intro Tour (react-joyride): - 8-step guided walkthrough highlighting Dashboard, Accounts, Assessments, Transactions, Budgets, Reports, and AI Investment Planning - Runs automatically on first login, tracked via has_seen_intro flag on user - Centralized step config in config/tourSteps.ts for easy text editing - data-tour attributes on Sidebar nav items and Dashboard for targeting Feature 2 - Tenant Onboarding Wizard: - 3-step modal wizard: create operating account, assessment group + units, import budget CSV - Runs after tour completes, tracked via onboardingComplete in org settings JSONB - Reuses existing API endpoints (POST /accounts, /assessment-groups, /units, /budgets/:year/import) Backend changes: - Add has_seen_intro column to shared.users + migration - Add PATCH /auth/intro-seen endpoint to mark tour complete - Add PATCH /organizations/settings endpoint for org settings updates - Include hasSeenIntro in login response, settings in switch-org response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -42,6 +43,15 @@ export class AuthController {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch('intro-seen')
|
||||
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async markIntroSeen(@Request() req: any) {
|
||||
await this.authService.markIntroSeen(req.user.sub);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('switch-org')
|
||||
@ApiOperation({ summary: 'Switch active organization' })
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -131,10 +131,15 @@ export class AuthService {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
role: membership.role,
|
||||
settings: membership.organization.settings || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async markIntroSeen(userId: string): Promise<void> {
|
||||
await this.usersService.markIntroSeen(userId);
|
||||
}
|
||||
|
||||
private async recordLoginHistory(
|
||||
userId: string,
|
||||
organizationId: string | null,
|
||||
@@ -185,6 +190,7 @@ export class AuthService {
|
||||
lastName: user.lastName,
|
||||
isSuperadmin: user.isSuperadmin || false,
|
||||
isPlatformOwner: user.isPlatformOwner || false,
|
||||
hasSeenIntro: user.hasSeenIntro || false,
|
||||
},
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Put, Patch, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
@@ -23,6 +23,13 @@ export class OrganizationsController {
|
||||
return this.orgService.findByUser(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@ApiOperation({ summary: 'Update settings for the current organization' })
|
||||
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
||||
this.requireTenantAdmin(req);
|
||||
return this.orgService.updateSettings(req.user.orgId, body);
|
||||
}
|
||||
|
||||
// ── Org Member Management ──
|
||||
|
||||
private requireTenantAdmin(req: any) {
|
||||
|
||||
@@ -78,6 +78,13 @@ export class OrganizationsService {
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async updateSettings(id: string, settings: Record<string, any>) {
|
||||
const org = await this.orgRepository.findOne({ where: { id } });
|
||||
if (!org) throw new NotFoundException('Organization not found');
|
||||
org.settings = { ...(org.settings || {}), ...settings };
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async findByUser(userId: string) {
|
||||
const memberships = await this.userOrgRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
|
||||
@@ -49,6 +49,9 @@ export class User {
|
||||
@Column({ name: 'is_platform_owner', default: false })
|
||||
isPlatformOwner: boolean;
|
||||
|
||||
@Column({ name: 'has_seen_intro', default: false })
|
||||
hasSeenIntro: boolean;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ export class UsersService {
|
||||
`);
|
||||
}
|
||||
|
||||
async markIntroSeen(id: string): Promise<void> {
|
||||
await this.usersRepository.update(id, { hasSeenIntro: true });
|
||||
}
|
||||
|
||||
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||
// Protect platform owner from having superadmin removed
|
||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||
|
||||
Reference in New Issue
Block a user