chore: rm the active company profile

This commit is contained in:
Nathnael
2026-07-22 06:43:05 +00:00
parent d95af0ea72
commit 82cb5508cc
23 changed files with 440 additions and 351 deletions

View File

@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -743,21 +742,13 @@ export class BookingsService {
);
companyProfileId = profile.id;
} else if (companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
// No explicit profile pin: resolve from the booking's trade direction
// (import→importer, export→exporter; otherwise the first profile). A
// forwarder booking sends dto.companyProfileId and takes the branch above.
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
tradeDirection,
fallbackType,
);
// A customer booking under their own account may only do so once the
@@ -1065,9 +1056,6 @@ export class BookingsService {
await this.companiesService.resolveCompanyProfileIdForBooking(
existing.companyId,
tradeDirection,
existing.companyProfileId
? undefined
: (existing.companyProfile?.type as ProfileType | undefined),
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
@@ -1389,15 +1377,6 @@ export class BookingsService {
}
}
/**
* Resolve the active company_profile id a customer's bookings should be
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
* back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
return this.companiesService.resolveActiveCompanyProfileId(userId);
}
/**
* Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong

View File

@@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
@@ -353,21 +352,6 @@ export class CompaniesController {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",
})
async setActiveMode(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetActiveModeDto,
): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.setActiveMode(
user.id,
dto.type,
);
return new CompanyInfoResponseDto(profile, company);
}
@Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -201,18 +201,6 @@ export class CompaniesService {
attributes: dto.attributes ?? null,
});
// Default active mode from the chosen role(s): importer wins when both are
// picked, otherwise the first allowed type chosen.
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const chosenTypes = (dto.companyProfiles ?? [])
.map((p) => p.type)
.filter((t) => allowedTypes.includes(t));
const activeProfileType =
chosenTypes.find((t) => t === ProfileType.importer) ??
chosenTypes[0] ??
allowedTypes[0] ??
null;
const profile = await this.profilesRepo.create({
userId: identity.userId,
companyId: company.id,
@@ -220,7 +208,6 @@ export class CompaniesService {
lastName: identity.lastName,
jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true,
activeProfileType,
onboardingStep: "company",
});
@@ -293,11 +280,6 @@ export class CompaniesService {
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const activeProfileType =
chosenTypes.find((t) => t === ProfileType.importer) ??
chosenTypes[0] ??
allowedTypes[0] ??
null;
const company = await this.companiesRepo.create({
name: identity.firstName
@@ -316,7 +298,6 @@ export class CompaniesService {
firstName: identity.firstName,
lastName: identity.lastName,
isPrimaryContact: true,
activeProfileType,
onboardingStep: "company",
onboardingCompleted: false,
});
@@ -1213,40 +1194,50 @@ export class CompaniesService {
if (!updated)
throw new NotFoundException(`Company profile ${existing.id} not found`);
// Suspension and reactivation lock/unlock a role the customer relies on —
// tell them, and carry the staff message so they know why.
const reactivated =
status === ProfileStatus.Active &&
existing.status === ProfileStatus.Suspended;
if (status === ProfileStatus.Suspended || reactivated) {
// Every reviewed transition that changes what the customer can do is told
// to them, carrying the staff message so they know why. Approval has no
// message (the note is cleared); the others require one.
const change =
status === ProfileStatus.Suspended
? "suspended"
: status === ProfileStatus.Rejected
? "rejected"
: status === ProfileStatus.Active
? existing.status === ProfileStatus.Suspended
? "reactivated"
: "approved"
: null;
if (change) {
const company = await this.companiesRepo.findById(updated.companyId);
if (company) {
this.companyNotifier.profileStatusChanged(
company,
updated.type,
status === ProfileStatus.Suspended ? "suspended" : "reactivated",
change,
note ?? "",
);
}
}
// Approving any profile promotes a pending company to active, so the
// customer can start working as soon as their first profile is cleared.
if (status === ProfileStatus.Active) {
const company = await this.companiesRepo.findById(updated.companyId);
if (company && company.status === CompanyStatus.Pending) {
await this.companiesRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
// The first approved role promotes a pending company to active — a
// bigger event (the account itself goes live), so tell them that too.
if (
status === ProfileStatus.Active &&
company.status === CompanyStatus.Pending
) {
await this.companiesRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
this.companyNotifier.companyApproved(company);
}
}
}
return updated;
}
/**
* Customer reapplies for a rejected operational role (after fixing whatever the
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
* clear the rejection note so it re-enters the approval queue.
* Customer reapplies for a rejected or suspended operational role (after
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
* back to Pending and clear the review note so it re-enters the approval
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
* backoffice still has to approve before the role goes live again.
*/
async reapplyCompanyProfile(
userId: string,
@@ -1261,9 +1252,12 @@ export class CompaniesService {
if (!target || target.companyId !== companyId) {
throw new NotFoundException(`Company profile ${profileId} not found`);
}
if (target.status !== ProfileStatus.Rejected) {
if (
target.status !== ProfileStatus.Rejected &&
target.status !== ProfileStatus.Suspended
) {
throw new BadRequestException(
"Only a rejected role can be resubmitted for approval",
"Only a rejected or suspended role can be resubmitted for approval",
);
}
@@ -1387,10 +1381,9 @@ export class CompaniesService {
/**
* 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.
* role starts Pending and carries no reference until a backoffice reviewer
* approves it; a booking/contract resolves its profile from the trade
* direction at creation time, so no "active mode" is stored.
*/
async createCompanyProfileForUser(
userId: string,
@@ -1425,40 +1418,6 @@ export class CompaniesService {
return created;
}
/**
* Switch the user's active operational mode. The target profile must already
* exist — clients create it first via createCompanyProfileForUser.
*/
async setActiveMode(
userId: string,
type: ProfileType,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (!existing) {
throw new ConflictException(
`No ${type} profile exists yet — create it before switching`,
);
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return this.getCompanyInfoByUserId(userId);
}
async setOnboardingStep(userId: string, step: string): Promise<void> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
@@ -2329,15 +2288,14 @@ export class CompaniesService {
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
* when the natural profile doesn't exist) it falls back to the user's active
* profile, then the company's first profile. Returns null when the company
* has no profiles at all.
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
* e.g. a freight forwarder) it falls back to the company's first profile.
* Callers that need a specific role (a forwarder) pass an explicit
* companyProfileId instead. Returns null when the company has no profiles.
*/
async resolveCompanyProfileIdForBooking(
companyId: string,
tradeDirection: string,
fallbackType?: ProfileType | null,
): Promise<string | null> {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
if (profiles.length === 0) return null;
@@ -2349,30 +2307,12 @@ export class CompaniesService {
? ProfileType.exporter
: null;
const byType = (type?: ProfileType | null) =>
type ? profiles.find((p) => p.type === type) : undefined;
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
const match =
(naturalType && profiles.find((p) => p.type === naturalType)) ??
profiles[0];
return match?.id ?? null;
}
/**
* Resolve the company_profile a customer's data should be scoped to, from
* their persisted active mode. Returns null when nothing can be resolved
* (not onboarded yet) so callers can fall back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
try {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const type = profile.activeProfileType;
if (!type) return null;
const match = company.companyProfiles?.find((p) => p.type === type);
return match?.id ?? null;
} catch {
return null;
}
}
async fetchETradeData(tin: string) {
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);

View File

@@ -59,23 +59,13 @@ export class CompanyNotifierService {
}
}
/**
* 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}`);
/** SMS + email + in-app account-status item to the company contact. */
private notifyAccount(
company: Company,
title: string,
body: string,
link = "/settings",
): void {
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
@@ -83,32 +73,88 @@ export class CompanyNotifierService {
type: NotificationType.ACCOUNT_STATUS,
title,
body,
link: "/settings",
data: { companyId: company.id, status },
link,
data: { companyId: company.id, status: company.status },
priority: NotificationPriority.HIGH,
});
}
/**
* Tell the customer one of their operational roles was suspended or
* reactivated, quoting the staff message — the service layer requires one for
* both transitions, so the customer always learns why, not just what.
* Tell the customer their account changed status. Fires on the transitions
* that change what they can do: suspended/blacklisted (locked out) and
* reactivated (back to Active from a lockout). Silent otherwise.
*/
statusChanged(company: Company, previous: CompanyStatus): void {
const status = company.status;
if (status === previous) return;
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
this.notifyAccount(
company,
"Account reactivated",
"Your company account has been reactivated. " +
"You can submit new contracts and bookings again.",
);
return;
}
if (!PUNITIVE_STATUSES.includes(status)) return;
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
this.logger.log(`ACCOUNT_${label.toUpperCase()}${company.id}`);
this.notifyAccount(
company,
`Account ${label}`,
`Your company account has been ${label}. ` +
`You will not be able to submit new contracts or bookings. ` +
`Please contact EDR support for assistance.`,
);
}
/**
* Tell the customer their company account was approved and is now live — the
* first operational role clearing review promotes a pending company to Active.
*/
companyApproved(company: Company): void {
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
this.notifyAccount(
company,
"Account approved",
"Your company account has been approved and is now active. " +
"You can start submitting bookings and contracts.",
"/dashboard",
);
}
/**
* Tell the customer one of their operational roles changed review status —
* approved, rejected, suspended, or reactivated — quoting the staff message
* when one was given (rejection/suspension/reactivation require one; approval
* carries none).
*/
profileStatusChanged(
company: Company,
profileType: string,
change: "suspended" | "reactivated",
change: "approved" | "rejected" | "suspended" | "reactivated",
staffMessage: string,
): void {
const title = `${profileType} role ${change}`;
const consequence =
change === "suspended"
? `You will not be able to operate under this role until it is reactivated; ` +
`your other roles are unaffected.`
: `You can operate under this role again.`;
const consequence: Record<typeof change, string> = {
approved: "You can now operate under this role.",
rejected:
"You will not be able to operate under this role. Amend the required " +
"documents and resubmit it for approval from your settings page.",
suspended:
"You will not be able to operate under this role until it is " +
"reactivated; your other roles are unaffected.",
reactivated: "You can operate under this role again.",
};
const message = staffMessage.trim();
const body =
`Your company's ${profileType} role has been ${change}. ` +
`${consequence} Message from EDR staff: ${staffMessage}`;
`${consequence[change]}` +
(message ? ` Message from EDR staff: ${message}` : "");
this.logger.log(
`PROFILE_${change.toUpperCase()}${company.id} / ${profileType}`,
@@ -121,7 +167,7 @@ export class CompanyNotifierService {
title,
body,
link: "/settings",
data: { companyId: company.id, profileType, change, staffMessage },
data: { companyId: company.id, profileType, change, staffMessage: message },
priority: NotificationPriority.HIGH,
});
}

View File

@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
company: Company,
changeRequest?: CompanyChangeRequest | null,
) {
this.profile = new ResponseExternalProfileDto(profile, company);
this.profile = new ResponseExternalProfileDto(profile);
this.company = new ResponseCompanyDto(company);
const open =

View File

@@ -1,8 +1,6 @@
import { Company } from '../entities/company.entity';
import {
ExternalProfile,
} from '../entities/external-profile.entity';
import { ProfileType } from '../entities/company-profile.entity';
export class ResponseExternalProfileDto {
id: string;
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
nationalId?: string | null;
jobTitle?: string | null;
isPrimaryContact: boolean;
/** The active operational mode (importer/exporter/forwarder). */
activeProfileType?: ProfileType | null;
/**
* The id of the company_profile matching activeProfileType, resolved
* server-side so the client never re-derives it. Null until a company
* (with profiles) is loaded and a matching profile exists.
*/
activeCompanyProfileId?: string | null;
onboardingStep?: string | null;
onboardingCompleted: boolean;
createdAt: Date;
updatedAt: Date;
constructor(profile: ExternalProfile, company?: Company) {
constructor(profile: ExternalProfile) {
this.id = profile.id;
this.userId = profile.userId;
this.companyId = profile.companyId;
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
this.nationalId = profile.nationalId;
this.jobTitle = profile.jobTitle;
this.isPrimaryContact = profile.isPrimaryContact;
this.activeProfileType = profile.activeProfileType ?? null;
this.onboardingStep = profile.onboardingStep ?? null;
this.onboardingCompleted = profile.onboardingCompleted ?? false;
this.activeCompanyProfileId =
company?.companyProfiles?.find(
(p) => p.type === profile.activeProfileType,
)?.id ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}

View File

@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { ProfileType } from '../entities/company-profile.entity';
export class SetActiveModeDto {
@IsEnum(ProfileType)
type!: ProfileType;
}

View File

@@ -1,7 +1,6 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { Company } from './company.entity';
import { ProfileType } from './company-profile.entity';
@Entity({ schema: 'freight', name: 'external_profiles' })
@Index(['userId'])
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
isPrimaryContact!: boolean;
/**
* The operational profile the user is currently "in" (importer vs exporter,
* or the single forwarder profile). Drives header switching and scopes the
* customer's bookings / dashboard to that company_profile. Nullable for
* users who haven't picked a role yet.
*/
@Column({
name: 'active_profile_type',
type: 'varchar',
length: 32,
nullable: true,
enum: ProfileType,
})
activeProfileType?: ProfileType | null;
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
@Column({
name: 'onboarding_step',

View File

@@ -12,7 +12,6 @@ import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
@@ -188,31 +187,31 @@ export class ContractsService {
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping.
// Stamp the operational profile for portal scoping. A forwarder contract
// pins its profile explicitly (trade direction can't tell it apart from a
// direct import/export); everything else resolves from the trade direction.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
dto.tradeDirection,
fallbackType,
);
if (dto.companyProfileId) {
const profile =
await this.companiesService.getActiveCompanyProfileForBooking(
companyId,
dto.companyProfileId,
);
companyProfileId = profile.id;
} else {
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
dto.tradeDirection,
);
const customerSelfBooking = !dto.companyId && !!userId;
if (customerSelfBooking && companyProfileId) {
await this.companiesService.assertCompanyProfileApprovedForBooking(
companyProfileId,
);
const customerSelfBooking = !dto.companyId && !!userId;
if (customerSelfBooking && companyProfileId) {
await this.companiesService.assertCompanyProfileApprovedForBooking(
companyProfileId,
);
}
}
}

View File

@@ -124,6 +124,16 @@ export class CreateContractDto {
@IsUUID()
companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Explicit company profile to stamp the contract to (a forwarder contract); ' +
'commercial contracts otherwise auto-resolve from trade direction.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
@IsIn([...CONTRACT_KINDS])
contractKind!: string;