mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 02:00:56 +00:00
fix: prevent the backoffice from approve the user before he submits
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user