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>
73 lines
2.3 KiB
TypeScript
73 lines
2.3 KiB
TypeScript
import { Injectable, ForbiddenException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { User } from './entities/user.entity';
|
|
|
|
@Injectable()
|
|
export class UsersService {
|
|
constructor(
|
|
@InjectRepository(User)
|
|
private usersRepository: Repository<User>,
|
|
) {}
|
|
|
|
async findByEmail(email: string): Promise<User | null> {
|
|
return this.usersRepository.findOne({
|
|
where: { email: email.toLowerCase() },
|
|
});
|
|
}
|
|
|
|
async findById(id: string): Promise<User | null> {
|
|
return this.usersRepository.findOne({ where: { id } });
|
|
}
|
|
|
|
async findByIdWithOrgs(id: string): Promise<User | null> {
|
|
return this.usersRepository.findOne({
|
|
where: { id },
|
|
relations: ['userOrganizations', 'userOrganizations.organization'],
|
|
});
|
|
}
|
|
|
|
async create(data: Partial<User>): Promise<User> {
|
|
const user = this.usersRepository.create({
|
|
...data,
|
|
email: data.email?.toLowerCase(),
|
|
});
|
|
return this.usersRepository.save(user);
|
|
}
|
|
|
|
async updateLastLogin(id: string): Promise<void> {
|
|
await this.usersRepository.update(id, { lastLoginAt: new Date() });
|
|
}
|
|
|
|
async findAllUsers(): Promise<User[]> {
|
|
return this.usersRepository.find({
|
|
relations: ['userOrganizations', 'userOrganizations.organization'],
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
async findAllOrganizations(): Promise<any[]> {
|
|
const dataSource = this.usersRepository.manager.connection;
|
|
return dataSource.query(`
|
|
SELECT o.*,
|
|
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count,
|
|
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_activity
|
|
FROM shared.organizations o
|
|
ORDER BY o.created_at DESC
|
|
`);
|
|
}
|
|
|
|
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 } });
|
|
if (user?.isPlatformOwner) {
|
|
throw new ForbiddenException('Cannot modify platform owner superadmin status');
|
|
}
|
|
await this.usersRepository.update(userId, { isSuperadmin });
|
|
}
|
|
}
|