mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Marketing asked for approval requests to surface in order instead of the alphabetical default. New sortBy=review tiers the list by what needs action - submitted applications awaiting first approval, then approved customers with a pending change request, then everyone else (drafts included) - newest first within each tier. Exposed as the backoffice "Needs review first" sort option and made the default on both ends. EDRFREIGHT-232
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { BaseRepository } from '@edr/api-common';
|
|
import { Company } from './entities/company.entity';
|
|
import { ListCompaniesQueryDto } from './dto/list-companies-query.dto';
|
|
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
|
|
)
|
|
)`;
|
|
|
|
/**
|
|
* A company waiting on a reviewer to decide an edit it submitted after being
|
|
* approved. These rows are `status = active`, so the pending-application filter
|
|
* can never surface them — the review queue needs its own predicate.
|
|
*/
|
|
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
|
|
SELECT 1 FROM freight.company_change_request ccr
|
|
WHERE ccr.company_id = company.id
|
|
AND ccr.status = 'pending'
|
|
AND ccr.deleted_at IS NULL
|
|
)`;
|
|
|
|
/**
|
|
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
|
|
* floats to the top. Tier 0 — submitted applications awaiting first approval
|
|
* (drafts excluded: nothing to review yet). Tier 1 — approved customers with
|
|
* a pending change request. Tier 2 — everyone else, drafts included.
|
|
*/
|
|
private static readonly REVIEW_TIER_SQL = `(CASE
|
|
WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0
|
|
WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1
|
|
ELSE 2
|
|
END)`;
|
|
|
|
constructor(
|
|
@InjectRepository(Company)
|
|
repo: Repository<Company>,
|
|
) {
|
|
super(repo);
|
|
}
|
|
|
|
async findByTin(tin: string): Promise<Company | null> {
|
|
return this.repository.findOne({ where: { tin } as any });
|
|
}
|
|
|
|
async findByType(type: string): Promise<Company[]> {
|
|
return this.repository.find({ where: { type } as any, order: { name: 'ASC' } });
|
|
}
|
|
|
|
async findByName(name: string): Promise<Company[]> {
|
|
return this.repository
|
|
.createQueryBuilder('company')
|
|
.where('company.name ILIKE :name', { name: `%${name}%` })
|
|
.getMany();
|
|
}
|
|
|
|
async existsByTin(tin: string): Promise<boolean> {
|
|
const count = await this.repository.count({ where: { tin } as any });
|
|
return count > 0;
|
|
}
|
|
|
|
async findPaginated(
|
|
query: ListCompaniesQueryDto,
|
|
): Promise<{ items: Company[]; total: number }> {
|
|
const {
|
|
page = 1,
|
|
pageSize = 20,
|
|
search,
|
|
type,
|
|
kind,
|
|
status,
|
|
onboardingCompleted,
|
|
hasPendingChangeRequest,
|
|
sortBy = 'review',
|
|
sortOrder = 'DESC',
|
|
} = 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) {
|
|
qb.andWhere('company.type = :type', { type });
|
|
}
|
|
|
|
if (kind) {
|
|
qb.andWhere('company.kind = :kind', { kind });
|
|
}
|
|
|
|
if (status) {
|
|
qb.andWhere('company.status = :status', { status });
|
|
}
|
|
|
|
if (onboardingCompleted !== undefined) {
|
|
qb.andWhere(
|
|
onboardingCompleted
|
|
? `NOT ${CompaniesRepository.DRAFT_SQL}`
|
|
: CompaniesRepository.DRAFT_SQL,
|
|
);
|
|
}
|
|
|
|
if (hasPendingChangeRequest !== undefined) {
|
|
qb.andWhere(
|
|
hasPendingChangeRequest
|
|
? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL
|
|
: `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`,
|
|
);
|
|
}
|
|
|
|
if (search) {
|
|
const term = `%${search.trim()}%`;
|
|
qb.andWhere(
|
|
`(company.name ILIKE :term
|
|
OR company.tin ILIKE :term
|
|
OR company.email ILIKE :term
|
|
OR EXISTS (
|
|
SELECT 1 FROM freight.company_profiles cp
|
|
WHERE cp.company_id = company.id
|
|
AND cp.reference ILIKE :term
|
|
AND cp.deleted_at IS NULL
|
|
))`,
|
|
{ term },
|
|
);
|
|
}
|
|
|
|
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
|
if (sortBy === 'review') {
|
|
// Queue ordering: actionable tiers first, newest first within each. The
|
|
// tier is selected under an alias because skip/take pagination with
|
|
// joins re-derives the ORDER BY in a subquery — a raw expression there
|
|
// breaks, a selected alias survives.
|
|
qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier')
|
|
.orderBy('review_tier', 'ASC')
|
|
.addOrderBy('company.createdAt', 'DESC');
|
|
} else {
|
|
qb.orderBy(`company.${sortBy}`, sortOrder);
|
|
}
|
|
const [items, total] = await qb
|
|
// Names are not unique and createdAt can tie on bulk imports; the id
|
|
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
|
.addOrderBy('company.id', 'ASC')
|
|
.skip((page - 1) * pageSize)
|
|
.take(pageSize)
|
|
.getManyAndCount();
|
|
|
|
return { items, total };
|
|
}
|
|
|
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
|
// 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 pendingChanges = await this.repository
|
|
.createQueryBuilder('company')
|
|
.where('company.deleted_at IS NULL')
|
|
.andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL)
|
|
.getCount();
|
|
|
|
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,
|
|
pendingChanges,
|
|
};
|
|
}
|
|
}
|