fix: the issue in the sheets file

This commit is contained in:
Nathnael
2026-07-10 12:10:17 +00:00
parent bd4d7b72fd
commit f737e401b3
23 changed files with 314 additions and 70 deletions

View File

@@ -221,7 +221,7 @@ export class CompaniesController {
@Post("company-profile")
@ApiOperation({
summary:
"Create a single operational profile for the current user's company and make it the active mode",
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
})
async createCompanyProfile(
@CurrentUser() user: CurrentIamUser,

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { MinioModule } from "../minio/minio.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
@@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
@Module({
imports: [
@@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service";
FilesModule,
FileUploadSettingsModule,
MinioModule,
// Account-status notifications (CompanyNotifierService). The inbox module
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
],
controllers: [CompaniesController],
providers: [
@@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service";
CompanyChangeRequestRepository,
CompanyDashboardRepository,
ETradeService,
CompanyNotifierService,
],
exports: [
CompaniesService,

View File

@@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
@@ -96,6 +97,7 @@ export class CompaniesService {
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
) { }
/**
@@ -585,9 +587,13 @@ export class CompaniesService {
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
await this.findCompanyById(id);
const before = await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
// This is the only path that writes those statuses.
this.companyNotifier.statusChanged(updated, before.status);
return updated;
}
@@ -1051,13 +1057,12 @@ export class CompaniesService {
);
}
const reference = await this.companyProfilesRepo.generateReference(type);
// No reference is minted here: it is issued by setCompanyProfileStatus when
// a reviewer approves the role. Creating it Active would bypass that review.
return this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
@@ -1131,9 +1136,11 @@ export class CompaniesService {
}
/**
* Create a single operational profile for the current user's company and
* make it the active mode in the same call. Powers the header "Switch to
* Exporter/Importer" flow when the target profile doesn't exist yet.
* Create a single operational profile for the current user's company. The new
* role starts Pending, so it deliberately does NOT become the active mode:
* switching onto an unapproved profile would strip the user of `canBook` and
* block them from creating contracts under the role they already had approved.
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
*/
async createCompanyProfileForUser(
userId: string,
@@ -1156,8 +1163,7 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved. The customer can select this mode but
// can't book under it until it's cleared.
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
@@ -1166,8 +1172,6 @@ export class CompaniesService {
});
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return created;
}
@@ -1442,10 +1446,12 @@ export class CompaniesService {
// browser (which fails on the internal bucket endpoint).
/**
* Upload business-license file(s) for one of the user's profiles. During
* onboarding (company not yet Active) they go live immediately; for an Active
* company they're staged under the pending code and recorded as `add` intents
* on a pending change request for backoffice review. Returns the updated view.
* Upload business-license file(s) for one of the user's profiles. For a role
* not yet approved (a fresh onboarding profile, or a newly added service on an
* already-active company) they go live immediately and are reviewed together
* with the role itself. Only for an already-approved role are they staged under
* the pending code and recorded as `add` intents on a pending change request —
* a licence swap on a live role is a change; a licence on a new role is not.
*/
async addProfileLicenseFiles(
userId: string,
@@ -1454,7 +1460,7 @@ export class CompaniesService {
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
const uploaded = await Promise.all(
@@ -1486,9 +1492,9 @@ export class CompaniesService {
/**
* Remove a license file. A staged (pending) file is withdrawn outright
* (soft-deleted, its `add` intent dropped). A live file on an Active company
* is kept and recorded as a `remove` intent for review; during onboarding it
* is deleted immediately.
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
* role is kept and recorded as a `remove` intent for review; on a role still
* awaiting approval it is deleted immediately.
*/
async removeProfileLicenseFile(
userId: string,
@@ -1504,7 +1510,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
if (record.code === LICENSE_PENDING_CODE) {
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
@@ -1526,7 +1532,7 @@ export class CompaniesService {
/**
* Replace a live license file with a freshly uploaded one — recorded as a
* `remove` of the old file plus an `add` of the new, so approval swaps them
* atomically. During onboarding the swap is applied immediately.
* atomically. On a role still awaiting approval the swap is applied immediately.
*/
async replaceProfileLicenseFile(
userId: string,
@@ -1540,7 +1546,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const created = await this.filesService.upload({
resourceId: profileId,
@@ -2044,13 +2050,17 @@ export class CompaniesService {
}
async fetchETradeData(tin: string) {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
const registrationData = this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}

View File

@@ -0,0 +1,86 @@
import { Injectable, Logger } from "@nestjs/common";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
/** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
CompanyStatus.Suspended,
CompanyStatus.Blacklisted,
];
/**
* Customer notifications for company account-status changes. Mirrors
* {@link ContractNotifierService}: SMS + email direct to the company contact,
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
* a notification failure must not roll back the status change itself.
*/
@Injectable()
export class CompanyNotifierService {
private readonly logger = new Logger(CompanyNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
/** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> {
const phone = company.contactPersonPhone ?? company.phone ?? null;
const email = company.email ?? company.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend("sms", phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend("email", email, message);
} catch (err) {
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${company.id} — not notified`);
}
}
/**
* Tell the customer their account was suspended or blacklisted. Called only on
* a real transition into one of those statuses; other status writes are silent.
*/
statusChanged(company: Company, previous: CompanyStatus): void {
const status = company.status;
if (status === previous) return;
if (!PUNITIVE_STATUSES.includes(status)) return;
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
const title = `Account ${label}`;
const body =
`Your company account has been ${label}. ` +
`You will not be able to submit new contracts or bookings. ` +
`Please contact EDR support for assistance.`;
this.logger.log(`ACCOUNT_${label.toUpperCase()}${company.id}`);
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.ACCOUNT_STATUS,
title,
body,
link: "/settings",
data: { companyId: company.id, status },
priority: NotificationPriority.HIGH,
});
}
}

View File

@@ -1,6 +1,7 @@
import { CompanyRegistrationData } from "@edr/types";
export class ETradeResponseDto implements CompanyRegistrationData {
companyName!: string;
licenceNumber!: string;
statusDescription!: string;
dateRegistered!: string;
@@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
tinTaken?: boolean;
constructor(data: CompanyRegistrationData) {
this.companyName = data.companyName;
this.licenceNumber = data.licenceNumber;
this.statusDescription = data.statusDescription;
this.dateRegistered = data.dateRegistered;

View File

@@ -84,11 +84,17 @@ export class CompanyProfile extends BaseEntity {
})
reference!: string | null;
/**
* A newly requested operational role is unreviewed, so it defaults to Pending.
* Only {@link CompaniesService.setCompanyProfileStatus} may promote it to
* Active — an approved-by-default role would let a customer self-grant a
* service (e.g. importer) without any documentation review.
*/
@Column({
name: "status",
type: "varchar",
length: 32,
default: ProfileStatus.Active,
default: ProfileStatus.Pending,
})
status!: ProfileStatus;

View File

@@ -87,12 +87,21 @@ export class ETradeService {
}
}
/**
* `companyInfo` carries the registered organization name (`BusinessName`);
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
* company name resolves to the legal entity rather than the trade name — and
* never to `ManagerNameEng`, which is the manager's personal name.
*/
extractRegistrationData(
businessInfo: ETradeBusinessInfo,
companyInfo?: ETradeCompanyInfo,
): CompanyRegistrationData {
const primaryManager = businessInfo.AssociateShortInfos?.[0];
return {
companyName:
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
licenceNumber: businessInfo.LicenceNumber,
statusDescription: businessInfo.StatusDescription,
dateRegistered: businessInfo.DateRegistered,

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service";
@Module({
imports: [
TypeOrmModule.forFeature([Notification, User, Session]),
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
CompaniesModule,
// ExternalProfileRepository + CompanyProfileRepository (portal targeting).
// CompaniesModule imports this module back for CompanyNotifierService.
forwardRef(() => CompaniesModule),
// BackofficeService.getOrganizationEmployees (staff targeting)
BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out)