mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
feat: implement onboarding process with draft company and profile creation
This commit is contained in:
@@ -27,6 +27,7 @@ import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
@@ -108,6 +109,30 @@ export class CompaniesController {
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
@Post("onboarding/start")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
|
||||
})
|
||||
async startOnboarding(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: StartOnboardingDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const nameParts = (user.name?.en ?? "").split(" ");
|
||||
const { profile, company } = await this.companiesService.startOnboarding(
|
||||
{
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user.email ?? "",
|
||||
phone: user.phoneNumber ?? "",
|
||||
},
|
||||
dto.companyType,
|
||||
dto.roles,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post("company-profile")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
import { Company } from "./entities/company.entity";
|
||||
import { Company, CompanyStatus, CompanyType } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
CompanyProfile,
|
||||
@@ -137,6 +137,112 @@ export class CompaniesService {
|
||||
return { company, profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin onboarding: create a DRAFT company + the user's external profile + the
|
||||
* chosen operational role(s) up front, so every subsequent wizard step can
|
||||
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
||||
*
|
||||
* Idempotent: if the user already has a profile, returns it unchanged (only
|
||||
* adding any newly-chosen roles). The draft company carries a placeholder TIN
|
||||
* (the real one is filled on the Company Information step) and stays
|
||||
* status=pending / onboardingCompleted=false until the wizard finishes.
|
||||
*/
|
||||
async startOnboarding(
|
||||
identity: UserIdentity,
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
// Already started — reuse the existing draft, just ensure roles exist.
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
await this.ensureCompanyProfiles(companyId, companyType, roles);
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
// A profile may exist for the same email under a different IAM id — block
|
||||
// duplicates as the final create does.
|
||||
const byEmail = await this.profilesRepo.findByEmail(identity.email);
|
||||
if (byEmail) {
|
||||
throw new ConflictException(
|
||||
`Profile with email ${identity.email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: identity.firstName
|
||||
? `${identity.firstName}'s company`
|
||||
: "New company",
|
||||
type: companyType,
|
||||
tin: await this.generateDraftTin(),
|
||||
country: "Ethiopia",
|
||||
status: CompanyStatus.Pending,
|
||||
});
|
||||
|
||||
await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: identity.phone,
|
||||
isPrimaryContact: true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
|
||||
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/** Create any of the requested operational profiles that don't exist yet. */
|
||||
private async ensureCompanyProfiles(
|
||||
companyId: string,
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
): Promise<void> {
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
for (const type of roles) {
|
||||
if (!allowedTypes.includes(type)) continue;
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique 10-char placeholder TIN for a draft company (the column is
|
||||
* NOT NULL + unique). Overwritten with the real TIN on the company step.
|
||||
*/
|
||||
private async generateDraftTin(): Promise<string> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate =
|
||||
"D" + Math.floor(Math.random() * 1_000_000_000).toString().padStart(9, "0");
|
||||
if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
|
||||
}
|
||||
// Extremely unlikely; fall back to a timestamp-derived value.
|
||||
return ("D" + Date.now().toString()).slice(0, 10);
|
||||
}
|
||||
|
||||
async findAllCompanies(): Promise<Company[]> {
|
||||
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
||||
}
|
||||
@@ -356,7 +462,17 @@ export class CompaniesService {
|
||||
companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined)
|
||||
companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin) {
|
||||
// Reject a TIN already taken by a different company (the user's own draft
|
||||
// placeholder is fine to overwrite).
|
||||
const owner = await this.companiesRepo.findByTin(dto.tin);
|
||||
if (owner && owner.id !== company.id) {
|
||||
throw new ConflictException(
|
||||
`Company with TIN ${dto.tin} already exists`,
|
||||
);
|
||||
}
|
||||
companyUpdates.tin = dto.tin;
|
||||
}
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
@@ -619,9 +735,23 @@ export class CompaniesService {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
// Guard against finishing on a still-draft company (TIN never filled in).
|
||||
if (!company.tin || company.tin.startsWith("D")) {
|
||||
throw new BadRequestException(
|
||||
"Company information is incomplete — please fill in your company details before finishing.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, {
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: 'done',
|
||||
onboardingStep: "done",
|
||||
});
|
||||
await this.companiesRepo.update(companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrayMinSize, IsArray, IsEnum } from "class-validator";
|
||||
import { CompanyType } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class StartOnboardingDto {
|
||||
@IsEnum(CompanyType)
|
||||
companyType!: CompanyType;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(ProfileType, { each: true })
|
||||
roles!: ProfileType[];
|
||||
}
|
||||
Reference in New Issue
Block a user