chore: reporting and filtering

This commit is contained in:
Nathnael
2026-08-21 09:25:21 +00:00
parent f55645fea9
commit 25d13baa6f
23 changed files with 941 additions and 902 deletions

View File

@@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { Company } from './entities/company.entity';
import {
companyDraftSql,
companyPendingChangeRequestSql,
} from './company-scope.sql';
import { ListCompaniesQueryDto } from './dto/list-companies-query.dto';
import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@@ -15,31 +19,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
* 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
)
)`;
private static readonly DRAFT_SQL = companyDraftSql('company');
/**
* 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
)`;
private static readonly PENDING_CHANGE_REQUEST_SQL =
companyPendingChangeRequestSql('company');
/**
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
@@ -96,6 +79,9 @@ export class CompaniesRepository extends BaseRepository<Company> {
type,
kind,
status,
nationality,
createdFrom,
createdTo,
onboardingCompleted,
hasPendingChangeRequest,
sortBy = 'review',
@@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status });
}
if (nationality) {
qb.andWhere('company.nationality = :nationality', { nationality });
}
if (createdFrom) {
qb.andWhere('company.createdAt >= :createdFrom', { createdFrom });
}
if (createdTo) {
qb.andWhere('company.createdAt <= :createdTo', { createdTo });
}
if (onboardingCompleted !== undefined) {
qb.andWhere(
onboardingCompleted

View File

@@ -0,0 +1,40 @@
/**
* Two predicates that define a customer's review state but are NOT columns on
* `companies`. Shared verbatim by the list repository and the export dataset —
* the backoffice offers both as one Status filter, so an export that computed
* "onboarding draft" differently from the list would quietly disagree with the
* screen it was launched from.
*
* Each takes the query's table alias because the two callers use different
* ones (`company` in the repository, `c` in the dataset).
*/
/**
* Still in the portal onboarding wizard: has at least one external profile,
* none of them submitted. Such a row exists from the wizard's first click, so
* it must be excluded from the awaiting-approval queue.
*/
export const companyDraftSql = (alias: string): string => `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = ${alias}.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = ${alias}.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
/**
* An already-approved customer who edited their profile: they stay
* `status = active`, so no status filter can ever surface them.
*/
export const companyPendingChangeRequestSql = (alias: string): string => `EXISTS (
SELECT 1 FROM freight.company_change_request ccr
WHERE ccr.company_id = ${alias}.id
AND ccr.status = 'pending'
AND ccr.deleted_at IS NULL
)`;

View File

@@ -1,7 +1,20 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import {
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Min,
} from "class-validator";
import { Transform } from "class-transformer";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
import {
CompanyKind,
CompanyNationality,
CompanyStatus,
CompanyType,
} from "../entities/company.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -38,6 +51,21 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyStatus))
status?: CompanyStatus;
@ApiPropertyOptional({ enum: CompanyNationality })
@IsOptional()
@IsIn(Object.values(CompanyNationality))
nationality?: CompanyNationality;
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: "Registered on or before this instant (ISO)." })
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description:
"Filter by onboarding submission. `true` = reviewable applications; " +