feat: enhance onboarding process and company profile management

- Updated CompanyInfoResponseDto to include company profile data in response.
- Enhanced ResponseExternalProfileDto to include active profile type and onboarding details.
- Added new fields to ExternalProfile entity for active profile type and onboarding status.
- Implemented onboarding wizard dialog in the frontend to guide users through the onboarding process.
- Introduced API endpoints for managing company profiles and onboarding steps.
- Created migrations to add new columns for active mode and onboarding status in the database.
- Added functionality to switch between operational modes (importer/exporter) and create profiles as needed.
- Improved user experience by ensuring onboarding is enforced for new users and those without completed profiles.
This commit is contained in:
Marshal
2026-06-19 22:07:58 +00:00
parent ca5877d25a
commit ed91f817ff
22 changed files with 1163 additions and 81 deletions

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddActiveModeAndOnboardingToExternalProfiles1791000000000
implements MigrationInterface
{
name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS onboarding_step varchar(40);
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false;
`);
// Existing users already use the portal — never re-gate them behind the
// new onboarding wizard.
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_completed = true
WHERE onboarding_completed = false;
`);
// Backfill the active mode for existing users from their company's
// operational profiles. Prefer importer, then exporter, then whichever
// single profile the company has (forwarder/dj/transporter).
await queryRunner.query(`
UPDATE freight.external_profiles ep
SET active_profile_type = cp.type
FROM (
SELECT DISTINCT ON (company_id) company_id, type
FROM freight.company_profiles
ORDER BY company_id,
CASE type
WHEN 'importer' THEN 0
WHEN 'exporter' THEN 1
ELSE 2
END
) cp
WHERE ep.company_id = cp.company_id
AND ep.active_profile_type IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS onboarding_completed;
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS onboarding_step;
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS active_profile_type;
`);
}
}

View File

@@ -0,0 +1,94 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyProfileIdToBookings1791000000001
implements MigrationInterface
{
name = 'AddCompanyProfileIdToBookings1791000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS company_profile_id UUID;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id
ON freight.bookings(company_profile_id);
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id'
) THEN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_company_profile_id"
FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles(id);
END IF;
END $$;
`);
// Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter
// profile, for each booking's own company.
await queryRunner.query(`
UPDATE freight.bookings b
SET company_profile_id = cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = b.company_id
AND b.company_profile_id IS NULL
AND (
(b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR
(b.trade_direction = 'EXPORT' AND cp.type = 'exporter')
);
`);
// Forwarder / single-profile companies: one profile per company, so the
// mapping is unambiguous regardless of trade direction.
await queryRunner.query(`
UPDATE freight.bookings b
SET company_profile_id = cp.id
FROM freight.company_profiles cp
JOIN freight.companies c ON c.id = cp.company_id
WHERE cp.company_id = b.company_id
AND c.type <> 'customer'
AND b.company_profile_id IS NULL;
`);
// Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no
// matching profile): attribute to the company's importer profile, else its
// exporter profile, so nothing disappears from the customer's list.
await queryRunner.query(`
UPDATE freight.bookings b
SET company_profile_id = cp.id
FROM (
SELECT DISTINCT ON (company_id) company_id, id
FROM freight.company_profiles
ORDER BY company_id,
CASE type
WHEN 'importer' THEN 0
WHEN 'exporter' THEN 1
ELSE 2
END
) cp
WHERE cp.company_id = b.company_id
AND b.company_id IS NOT NULL
AND b.company_profile_id IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id";
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS company_profile_id;
`);
}
}

View File

@@ -133,7 +133,15 @@ export class BookingsController {
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) return { items: [], total: 0 };
return this.bookingsService.findAll(filter, companyId);
// Scope to the active operational profile (importer/exporter) when one
// resolves; otherwise fall back to company-level scoping.
const companyProfileId =
await this.bookingsService.resolveActiveCompanyProfileId(userId);
return this.bookingsService.findAll(
filter,
companyId,
companyProfileId ?? undefined,
);
}
@Get('list-summary')

View File

@@ -25,6 +25,7 @@ export interface BookingListFilterOptions {
schedulingStatuses?: string[];
assignedToSchedule?: 'true' | 'false';
companyId?: string;
companyProfileId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
@@ -559,6 +560,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
companyId: options.companyId,
});
}
if (options.companyProfileId) {
qb.andWhere('booking.company_profile_id = :companyProfileId', {
companyProfileId: options.companyProfileId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,

View File

@@ -10,6 +10,7 @@ import {
import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -323,6 +324,29 @@ export class BookingsService {
dto.tradeDirection,
);
// Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
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,
tradeDirection,
fallbackType,
);
}
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
@@ -348,6 +372,7 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
trainId: dto.trainId,
@@ -504,6 +529,22 @@ export class BookingsService {
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
// If the route (hence trade direction) changed, re-stamp the operational
// profile so an edited draft doesn't get stranded under the wrong profile.
if (
tradeDirection !== existing.tradeDirection &&
!existing.isGovernment &&
existing.companyId
) {
updates.companyProfileId =
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);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
@@ -583,6 +624,7 @@ export class BookingsService {
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -597,7 +639,10 @@ export class BookingsService {
assignedToSchedule: filter.assignedToSchedule,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
companyId: forceCompanyId ?? filter.companyId,
// When an active profile resolves, scope to it; otherwise fall back to the
// company so nothing breaks for not-yet-onboarded customers.
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -629,13 +674,18 @@ export class BookingsService {
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// Scope to the active operational profile when one resolves; fall back to
// company-level so not-yet-onboarded customers still see their payables.
const companyProfileId =
await this.companiesService.resolveActiveCompanyProfileId(userId);
return this.bookingsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20,
statuses: BookingsService.PAYABLE_STATUSES,
excludePaymentStatus: 'PAID',
companyId: company.id,
companyId: companyProfileId ? undefined : company.id,
companyProfileId: companyProfileId ?? undefined,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -655,6 +705,15 @@ 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

@@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
// import { Customer } from '../../customers/entities/customer.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
@@ -92,6 +93,20 @@ export class Booking extends BaseEntity {
@JoinColumn({ name: 'company_id' })
company?: Company | null;
/**
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
* legacy/government/staff-created bookings.
*/
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId?: string | null;
@ManyToOne(() => CompanyProfile, { nullable: true })
@JoinColumn({ name: 'company_profile_id' })
companyProfile?: CompanyProfile | null;
@Column({ name: 'is_government', type: 'boolean', default: false })
isGovernment!: boolean;

View File

@@ -24,6 +24,9 @@ import { UpdateCompanyDto } from "./dto/update-company.dto";
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 {
ResponseCompanyDto,
ResponseCompanyProfileDto,
@@ -105,6 +108,58 @@ export class CompaniesController {
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
@Post("company-profile")
@ApiOperation({
summary:
"Create a single operational profile for the current user's company and make it the active mode",
})
async createCompanyProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CreateCompanyProfileDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.createCompanyProfileForUser(
user.id,
dto.type,
dto.businessLicense,
);
return new ResponseCompanyProfileDto(profile);
}
@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)
async setOnboardingStep(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetOnboardingStepDto,
): Promise<void> {
await this.companiesService.setOnboardingStep(user.id, dto.step);
}
@Post("onboarding/complete")
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.markOnboardingComplete(user.id);
return new CompanyInfoResponseDto(profile, company);
}
// Used by portal
@Post("create")
@ApiOperation({

View File

@@ -81,6 +81,18 @@ 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,
@@ -90,6 +102,8 @@ export class CompaniesService {
phone: identity.phone,
jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true,
activeProfileType,
onboardingStep: 'company',
});
// Persist the operational role(s) chosen during onboarding. Types are
@@ -174,6 +188,18 @@ export class CompaniesService {
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
if (!companyId) return this.emptyDashboardSummary();
// Scope KPIs to the active operational profile (importer/exporter mode) when
// one resolves; otherwise aggregate across the whole company.
const companyProfileId = profile?.activeProfileType
? ((await this.companyProfilesRepo.findByType(
companyId,
profile.activeProfileType,
)) ?? null)
: null;
const scope = companyProfileId
? { companyProfileId: companyProfileId.id }
: { companyId };
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
@@ -191,22 +217,22 @@ export class CompaniesService {
tonnagePrev,
monthlyRows,
] = await Promise.all([
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
this.dashboardRepo.countDelivered(scope, yearStart, now),
this.dashboardRepo.countCommitted(scope, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(
companyId,
scope,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(
companyId,
scope,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.monthlyCommittedTonnage(
companyId,
scope,
this.monthsAgo(now, 5),
now,
),
@@ -505,4 +531,145 @@ export class CompaniesService {
return this.companyProfilesRepo.findByCompanyId(companyId);
}
/**
* 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.
*/
async createCompanyProfileForUser(
userId: string,
type: ProfileType,
businessLicense?: string,
): Promise<CompanyProfile> {
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}"`,
);
}
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
const reference = await this.companyProfilesRepo.generateReference(type);
created = await this.companyProfilesRepo.create({
companyId,
type,
reference,
businessLicense: businessLicense ?? null,
status: ProfileStatus.Active,
});
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
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)
throw new NotFoundException(`Profile for user ${userId} not found`);
await this.profilesRepo.update(profile.id, { onboardingStep: step });
}
async markOnboardingComplete(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
await this.profilesRepo.update(profile.id, {
onboardingCompleted: true,
onboardingStep: 'done',
});
return this.getCompanyInfoByUserId(userId);
}
/**
* 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.
*/
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;
const naturalType =
tradeDirection === 'IMPORT'
? ProfileType.importer
: tradeDirection === 'EXPORT'
? ProfileType.exporter
: null;
const byType = (type?: ProfileType | null) =>
type ? profiles.find((p) => p.type === type) : undefined;
const match = byType(naturalType) ?? byType(fallbackType) ?? 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;
}
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
@@ -31,6 +31,27 @@ export interface CurrencyTotal {
total: number;
}
/**
* What the dashboard is scoped to: a single operational profile (the active
* importer/exporter mode) when one resolves, otherwise the whole company
* (legacy / not-yet-onboarded fallback).
*/
export type DashboardScope =
| { companyProfileId: string }
| { companyId: string };
/** Apply the scope as a WHERE clause on a bookings query builder. */
function applyScope(
qb: SelectQueryBuilder<Booking>,
scope: DashboardScope,
): SelectQueryBuilder<Booking> {
return 'companyProfileId' in scope
? qb.where('b.company_profile_id = :companyProfileId', {
companyProfileId: scope.companyProfileId,
})
: qb.where('b.company_id = :companyId', { companyId: scope.companyId });
}
export interface MonthlyTonnage {
year: number;
month: number; // 1-12
@@ -50,35 +71,33 @@ export class CompanyDashboardRepository {
private readonly bookings: Repository<Booking>,
) {}
/** Count of delivered/completed bookings for a company within [from, to). */
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
/** Count of delivered/completed bookings within [from, to) for the scope. */
async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise<number> {
return applyScope(this.bookings.createQueryBuilder('b'), scope)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
/** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */
async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise<number> {
return applyScope(this.bookings.createQueryBuilder('b'), scope)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Sum of paid booking totals, grouped by currency, within [from, to). */
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('b.payment_currency', 'currency')
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
/** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */
async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise<CurrencyTotal[]> {
const rows = await applyScope(
this.bookings
.createQueryBuilder('b')
.select('b.payment_currency', 'currency')
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'),
scope,
)
.andWhere('b.deleted_at IS NULL')
.andWhere("b.payment_status = 'PAID'")
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
@@ -88,12 +107,14 @@ export class CompanyDashboardRepository {
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
}
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
const row = await this.bookings
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
/** Total committed tonnage (cargo VGM) within [from, to) for the scope. */
async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<number> {
const row = await applyScope(
this.bookings
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
scope,
)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
@@ -102,14 +123,16 @@ export class CompanyDashboardRepository {
return Number(row?.total ?? 0);
}
/** Committed tonnage grouped by calendar month within [from, to). */
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
/** Committed tonnage grouped by calendar month within [from, to) for the scope. */
async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<MonthlyTonnage[]> {
const rows = await applyScope(
this.bookings
.createQueryBuilder('b')
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
scope,
)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })

View File

@@ -8,7 +8,7 @@ export class CompanyInfoResponseDto {
company: ResponseCompanyDto;
constructor(profile: ExternalProfile, company: Company) {
this.profile = new ResponseExternalProfileDto(profile);
this.profile = new ResponseExternalProfileDto(profile, company);
this.company = new ResponseCompanyDto(company);
}
}

View File

@@ -0,0 +1,12 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { ProfileType } from '../entities/company-profile.entity';
export class CreateCompanyProfileDto {
@IsEnum(ProfileType)
type!: ProfileType;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
}

View File

@@ -1,4 +1,8 @@
import { ExternalProfile } from '../entities/external-profile.entity';
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;
@@ -11,10 +15,20 @@ 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) {
constructor(profile: ExternalProfile, company?: Company) {
this.id = profile.id;
this.userId = profile.userId;
this.companyId = profile.companyId;
@@ -25,6 +39,13 @@ 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

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

View File

@@ -0,0 +1,7 @@
import { IsString, MaxLength } from 'class-validator';
export class SetOnboardingStepDto {
@IsString()
@MaxLength(40)
step!: string;
}

View File

@@ -1,6 +1,7 @@
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'])
@@ -36,4 +37,31 @@ 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',
type: 'varchar',
length: 40,
nullable: true,
})
onboardingStep?: string | null;
@Column({ name: 'onboarding_completed', type: 'boolean', default: false })
onboardingCompleted!: boolean;
}