Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
80 lines
2.5 KiB
TypeScript
80 lines
2.5 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
|
|
const REQUIRED_STEPS = ['profile', 'workspace', 'invite_member', 'first_workflow'];
|
|
|
|
@Injectable()
|
|
export class OnboardingService {
|
|
private readonly logger = new Logger(OnboardingService.name);
|
|
|
|
constructor(private dataSource: DataSource) {}
|
|
|
|
async getProgress(orgId: string) {
|
|
const rows = await this.dataSource.query(
|
|
`SELECT completed_steps, completed_at, updated_at
|
|
FROM shared.onboarding_progress
|
|
WHERE organization_id = $1`,
|
|
[orgId],
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
// Create a fresh record
|
|
await this.dataSource.query(
|
|
`INSERT INTO shared.onboarding_progress (organization_id)
|
|
VALUES ($1) ON CONFLICT DO NOTHING`,
|
|
[orgId],
|
|
);
|
|
return { completedSteps: [], completedAt: null, requiredSteps: REQUIRED_STEPS };
|
|
}
|
|
|
|
return {
|
|
completedSteps: rows[0].completed_steps || [],
|
|
completedAt: rows[0].completed_at,
|
|
requiredSteps: REQUIRED_STEPS,
|
|
};
|
|
}
|
|
|
|
async markStepComplete(orgId: string, step: string) {
|
|
// Add step to array (using array_append with dedup)
|
|
await this.dataSource.query(
|
|
`INSERT INTO shared.onboarding_progress (organization_id, completed_steps, updated_at)
|
|
VALUES ($1, ARRAY[$2::text], NOW())
|
|
ON CONFLICT (organization_id)
|
|
DO UPDATE SET
|
|
completed_steps = CASE
|
|
WHEN $2 = ANY(onboarding_progress.completed_steps) THEN onboarding_progress.completed_steps
|
|
ELSE array_append(onboarding_progress.completed_steps, $2::text)
|
|
END,
|
|
updated_at = NOW()`,
|
|
[orgId, step],
|
|
);
|
|
|
|
// Check if all required steps are done
|
|
const rows = await this.dataSource.query(
|
|
`SELECT completed_steps FROM shared.onboarding_progress WHERE organization_id = $1`,
|
|
[orgId],
|
|
);
|
|
|
|
const completedSteps = rows[0]?.completed_steps || [];
|
|
const allDone = REQUIRED_STEPS.every((s) => completedSteps.includes(s));
|
|
|
|
if (allDone) {
|
|
await this.dataSource.query(
|
|
`UPDATE shared.onboarding_progress SET completed_at = NOW() WHERE organization_id = $1 AND completed_at IS NULL`,
|
|
[orgId],
|
|
);
|
|
}
|
|
|
|
return this.getProgress(orgId);
|
|
}
|
|
|
|
async resetProgress(orgId: string) {
|
|
await this.dataSource.query(
|
|
`UPDATE shared.onboarding_progress SET completed_steps = '{}', completed_at = NULL, updated_at = NOW()
|
|
WHERE organization_id = $1`,
|
|
[orgId],
|
|
);
|
|
return this.getProgress(orgId);
|
|
}
|
|
}
|