mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
Two review-workflow gaps for freight customer onboarding: Request for change per document. Backoffice can now flag a single uploaded document (company document, profile licence, or POA delegation letter) with a note the customer sees, instead of rejecting the whole role over it. Adds review_status/review_note/reviewed_by/reviewed_at to freight.files (migration AddFileReviewStatus, partial index for the gate), a POST documents/:fileId/request-change endpoint, the backoffice action + modal, and a portal banner/badge so the customer knows what to re-upload. Re-uploading clears the flag. Approving a role is blocked while any of its documents has an open correction; the gate check and the status write share a pessimistic write lock on the company row (as does the change-request write) so a correction can never slip in between the check and the profile going Active. Resubmission is visible to reviewers. When a customer resubmits a rejected role or amends a change request, backoffice staff are notified (allBackoffice inbox item, deep-linked to the customer) and the resubmission surfaces in a new "Pending changes" list view + KPI, since such companies are status = active and never matched the pending-approval filter.
194 lines
6.1 KiB
TypeScript
194 lines
6.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
|
|
)`;
|
|
|
|
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 = 'name',
|
|
sortOrder = 'ASC',
|
|
} = 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.
|
|
const [items, total] = await qb
|
|
.orderBy(`company.${sortBy}`, sortOrder)
|
|
// 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,
|
|
};
|
|
}
|
|
}
|