fix: prevent the backoffice from approve the user before he submits

This commit is contained in:
Nathnael
2026-07-17 08:54:24 +00:00
parent 0883c54465
commit ef74bc442a
9 changed files with 253 additions and 23 deletions

View File

@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@Injectable()
export class CompaniesRepository extends BaseRepository<Company> {
/**
* A company still being filled in by its owner in the portal wizard: it was
* self-registered (so it has an external profile) and nobody has submitted
* onboarding yet. The row exists from the wizard's first click, carrying a
* placeholder name + TIN, so it must not be offered up for review.
* Staff-created companies have no external profiles and are never drafts.
*/
private static readonly DRAFT_SQL = `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const {
page = 1,
pageSize = 20,
search,
type,
kind,
status,
onboardingCompleted,
} = query;
const qb = this.repository
.createQueryBuilder('company')
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
// External profiles carry onboardingCompleted, which the backoffice list
// uses to flag customers still mid-onboarding (not yet reviewable).
.leftJoinAndSelect('company.profiles', 'profiles')
.where('company.deleted_at IS NULL');
if (type) {
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status });
}
if (onboardingCompleted !== undefined) {
qb.andWhere(
onboardingCompleted
? `NOT ${CompaniesRepository.DRAFT_SQL}`
: CompaniesRepository.DRAFT_SQL,
);
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
}
async getStats(): Promise<CompanyStatsResponseDto> {
const rows: { status: string; count: string }[] = await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.getRawMany();
// Drafts are counted separately rather than under `pending`: they carry
// status=pending from creation, which would otherwise inflate the review
// queue's KPI with customers who haven't submitted anything yet.
const rows: { status: string; is_draft: boolean; count: string }[] =
await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.addGroupBy(CompaniesRepository.DRAFT_SQL)
.getRawMany();
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const map = new Map<string, number>();
let onboarding = 0;
let total = 0;
for (const row of rows) {
const count = parseInt(row.count, 10);
total += count;
if (row.is_draft) onboarding += count;
else map.set(row.status, (map.get(row.status) ?? 0) + count);
}
return {
total,
active: map.get('active') ?? 0,
pending: map.get('pending') ?? 0,
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
};

View File

@@ -372,6 +372,9 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id);
return company;
}
@@ -962,6 +965,28 @@ export class CompaniesService {
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
// application that doesn't exist yet. Staff-created companies have no
// external profiles and are exempt.
//
// Only the review decision itself is gated (a profile still awaiting one:
// Pending, or Rejected and awaiting re-approval). Profiles already in
// service stay managable so staff can suspend/blacklist them — including to
// undo an approval granted before this guard existed.
const awaitingReview =
existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected;
if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
);
}
}
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };

View File

@@ -1,7 +1,10 @@
export class CompanyStatsResponseDto {
total!: number;
active!: number;
/** Submitted applications awaiting review. Excludes drafts. */
pending!: number;
/** Self-registered companies still working through the onboarding wizard. */
onboarding!: number;
suspended!: number;
blacklisted!: number;
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
@IsOptional()
@IsIn(Object.values(CompanyStatus))
status?: CompanyStatus;
@ApiPropertyOptional({
description:
"Filter by onboarding submission. `true` = reviewable applications; " +
"`false` = drafts still in the portal wizard. Omit for both.",
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
onboardingCompleted?: boolean;
}

View File

@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[];
/**
* Whether the owning portal user has submitted the onboarding wizard.
* Approval decisions are blocked while this is false. Staff-created
* companies (no external profiles) count as completed. Undefined when the
* external profiles weren't loaded.
*/
onboardingCompleted?: boolean;
createdAt: Date;
updatedAt: Date;
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
this.companyProfiles = company.companyProfiles?.map(
(p) => new ResponseCompanyProfileDto(p),
);
this.onboardingCompleted = company.profiles
? company.profiles.length === 0 ||
company.profiles.some((p) => p.onboardingCompleted)
: undefined;
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}