feat(freight): record when a company is approved

Ticket #420 — add approved_at to companies (migration), stamped at
both promotion sites (first-profile auto-approve and manual staff
status change). Surfaced as Submitted on/Approved on in the backoffice
customer list and detail views.
This commit is contained in:
Nathnael
2026-07-31 11:43:36 +00:00
parent 83d3fc3b78
commit d67297a601
7 changed files with 85 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Pending→Active is the only company-level approval event; `updatedAt` can't
* stand in for it since any field edit bumps that too. Nullable — existing
* companies (approved before this column existed) have no recorded moment.
*/
export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface {
name = "AddApprovedAtToCompanies3120000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS approved_at timestamptz`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies
DROP COLUMN IF EXISTS approved_at`,
);
}
}

View File

@@ -682,7 +682,16 @@ export class CompaniesService {
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
const before = await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
// Staff can also promote Pending -> Active directly through this generic
// endpoint (not just via the first-profile-approval path), so stamp it here too.
if (
dto.status === CompanyStatus.Active &&
before.status !== CompanyStatus.Active
) {
patch.approvedAt = new Date();
}
const updated = await this.companiesRepo.update(id, patch);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
@@ -1472,6 +1481,7 @@ export class CompaniesService {
) {
await companyRepo.update(updated.companyId, {
status: CompanyStatus.Active,
approvedAt: new Date(),
});
this.companyNotifier.companyApproved(company);
}

View File

@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
createdAt: Date;
updatedAt: Date;
approvedAt: Date | null;
constructor(company: Company) {
this.id = company.id;
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
this.identity = buildCompanyIdentityState(company);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
this.approvedAt = company.approvedAt ?? null;
}
}

View File

@@ -61,6 +61,10 @@ export class Company extends BaseEntity {
})
status!: CompanyStatus;
/** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */
@Column({ name: "approved_at", type: "timestamptz", nullable: true })
approvedAt?: Date | null;
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
tin!: string;

View File

@@ -757,6 +757,18 @@ export default function CustomerDetailPage() {
<InfoField label="TIN" value={company.tin} />
<InfoField label="VAT number" value={company.vatNumber} />
<InfoField label="FAN number" value={company.fanNumber} />
<InfoField
label="Submitted on"
value={formatDate(company.createdAt)}
/>
<InfoField
label="Approved on"
value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/>
<InfoField
label="Owner identity"
value={

View File

@@ -245,6 +245,16 @@ export default function CustomersPage() {
</Text>
),
},
{
id: "approved",
header: "Approved",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
</Text>
),
},
],
[],
);

View File

@@ -110,6 +110,27 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
/** One field/document change recorded on a company revision. */
export interface CompanyRevisionChange {
field: string;
label: string;
from: string | null;
to: string | null;
}
/**
* Onboarding-phase edit history — records what changed on a company record
* before it reached Active, the write path that has no approval gate.
*/
export interface CompanyRevision {
id: string;
companyId: string;
actorId: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: string;
}
/** The channel a customer's password-reset link is delivered over. */
export type ResetChannel = "email" | "phone";
@@ -215,6 +236,7 @@ export interface Company {
onboardingCompleted?: boolean;
createdAt: string;
updatedAt: string;
approvedAt?: string | null;
}
/**