mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
fix schule issue and contianer type issue
This commit is contained in:
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { AccountService } from "./account.service";
|
||||
import {
|
||||
SendContactOtpDto,
|
||||
UpdateAccountNameDto,
|
||||
UpdateContactDto,
|
||||
} from "./dto/account.dto";
|
||||
|
||||
/**
|
||||
* The caller's own account record. Everything here is scoped to the JWT's user
|
||||
* id — there is no `:id` parameter to tamper with, so these routes need no
|
||||
* permission key beyond being authenticated.
|
||||
*/
|
||||
@ApiTags("auth")
|
||||
@Controller("me")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtGuard)
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
@Post("contact/otp")
|
||||
@ApiOperation({
|
||||
summary: "Send a verification code to a new email/phone before changing it",
|
||||
description:
|
||||
"The code goes to the NEW value supplied here, proving the caller controls " +
|
||||
"it. Returns the target masked — an unverified caller never gets it back in full.",
|
||||
})
|
||||
sendContactOtp(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: SendContactOtpDto,
|
||||
): Promise<{ sentTo: string }> {
|
||||
return this.accountService.sendContactOtp(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch("contact")
|
||||
@ApiOperation({
|
||||
summary: "Change the account's email or phone, gated by a verification code",
|
||||
description:
|
||||
"Verifies the code and writes the new value in one call, so the API never " +
|
||||
"has to take a client's word that verification happened.",
|
||||
})
|
||||
updateContact(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: UpdateContactDto,
|
||||
): Promise<{ success: true; value: string }> {
|
||||
return this.accountService.updateContact(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch("name")
|
||||
@ApiOperation({ summary: "Change the account's display name" })
|
||||
updateName(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: UpdateAccountNameDto,
|
||||
): Promise<{ success: true }> {
|
||||
return this.accountService.updateName(user.id, dto);
|
||||
}
|
||||
}
|
||||
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, EntityManager, Repository } from "typeorm";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
|
||||
import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type";
|
||||
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
|
||||
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import { OtpService, OtpTarget } from "../otp/otp.service";
|
||||
import {
|
||||
ContactChannel,
|
||||
SendContactOtpDto,
|
||||
UpdateAccountNameDto,
|
||||
UpdateContactDto,
|
||||
} from "./dto/account.dto";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
|
||||
/** How long a contact-change code stays valid before it must be re-requested. */
|
||||
const CONTACT_OTP_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Postgres unique-violation SQLSTATE. */
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
/**
|
||||
* Self-serve management of the caller's own IAM user record.
|
||||
*
|
||||
* IAM ships `PATCH /api/auth/update-profile`, but it takes email + username +
|
||||
* phone + name all at once (every field `@IsNotEmpty`) and performs no
|
||||
* verification — it will move an account's phone to any number the caller
|
||||
* types. These routes exist so a contact change is *proven*: the code goes to
|
||||
* the NEW address and the write only lands once it comes back.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
private readonly logger = new Logger(AccountService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly otpService: OtpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a code to the address the caller wants to move TO. Sending to the new
|
||||
* value (rather than the one on file) is the whole point — it proves control
|
||||
* of the destination before anything is written.
|
||||
*/
|
||||
async sendContactOtp(
|
||||
userId: string,
|
||||
dto: SendContactOtpDto,
|
||||
): Promise<{ sentTo: string }> {
|
||||
const value = this.normalize(dto.channel, dto.value);
|
||||
await this.assertNotTaken(dto.channel, value, userId);
|
||||
|
||||
const target = this.targetFor(dto.channel, value);
|
||||
await this.otpService.sendOtp(target);
|
||||
|
||||
return { sentTo: maskOtpTarget(target) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the code, then write the new contact value. The verify and the write
|
||||
* are one call: the API never has to trust that a client "already verified"
|
||||
* — unlike the signup flow, where the OTP is client-orchestrated and
|
||||
* `POST /api/otp/verify` is a separate public route the client may simply skip.
|
||||
*/
|
||||
async updateContact(
|
||||
userId: string,
|
||||
dto: UpdateContactDto,
|
||||
): Promise<{ success: true; value: string }> {
|
||||
const value = this.normalize(dto.channel, dto.value);
|
||||
await this.assertNotTaken(dto.channel, value, userId);
|
||||
|
||||
await this.otpService.verifyOtpForAction(
|
||||
this.targetFor(dto.channel, value),
|
||||
dto.otp,
|
||||
CONTACT_OTP_TTL_MS,
|
||||
);
|
||||
|
||||
const isEmail = dto.channel === ContactChannel.Email;
|
||||
const userPatch = isEmail
|
||||
? { email: value }
|
||||
: {
|
||||
phoneNumber: value,
|
||||
// The number just passed an OTP, which is exactly what IAM's own
|
||||
// phone-verification flag means. Set it here so the freight app stops
|
||||
// needing its own parallel "verified phone" bookkeeping.
|
||||
isPhoneNumberVerified: true,
|
||||
verifiedBy: EUserVerifiedBy.PHONE_NUMBER,
|
||||
};
|
||||
const sessionPatch: Partial<TCurrentTokenUser> = isEmail
|
||||
? { email: value }
|
||||
: { phoneNumber: value, isPhoneNumberVerified: true };
|
||||
|
||||
try {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(User).update({ id: userId }, userPatch);
|
||||
await this.refreshSessions(manager, userId, sessionPatch);
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.asConflict(error, dto.channel);
|
||||
}
|
||||
|
||||
this.logger.log(`Account ${dto.channel} updated for user ${userId}`);
|
||||
return { success: true, value };
|
||||
}
|
||||
|
||||
/** Rename the account. No OTP — a name change proves nothing and grants nothing. */
|
||||
async updateName(
|
||||
userId: string,
|
||||
dto: UpdateAccountNameDto,
|
||||
): Promise<{ success: true }> {
|
||||
const en = dto.name.en?.trim();
|
||||
const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) };
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(User).update({ id: userId }, { name });
|
||||
// IAM mirrors the name onto the employee row. Portal customers are
|
||||
// `individual` users with no employee row at all, so this is a no-op for
|
||||
// them — hence an unconditional update() rather than a lookup-then-write.
|
||||
await manager.getRepository(Employee).update({ userId }, { name });
|
||||
await this.refreshSessions(manager, userId, { name });
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only
|
||||
* when a session is created at login. Without patching it here, a saved change
|
||||
* stays invisible to /me (and to anything reading the token's claims) until the
|
||||
* user logs out and back in, which reads as "my edit didn't save".
|
||||
*/
|
||||
private async refreshSessions(
|
||||
manager: EntityManager,
|
||||
userId: string,
|
||||
patch: Partial<TCurrentTokenUser>,
|
||||
): Promise<void> {
|
||||
const repo = manager.getRepository(Session);
|
||||
const sessions = await repo.find({ where: { userId } });
|
||||
|
||||
await Promise.all(
|
||||
sessions.map((session) =>
|
||||
repo.update(
|
||||
{ id: session.id },
|
||||
{ userInfo: { ...session.userInfo, ...patch } },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Canonicalise for the channel and reject anything malformed up front. */
|
||||
private normalize(channel: ContactChannel, value: string): string {
|
||||
const raw = value.trim();
|
||||
|
||||
if (channel === ContactChannel.Email) {
|
||||
const email = raw.toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw new BadRequestException("A valid email address is required");
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
if (!isValidPhoneNumber(raw)) {
|
||||
throw new BadRequestException(
|
||||
"A valid international phone number is required (E.164, e.g. +251911223344)",
|
||||
);
|
||||
}
|
||||
// Store the same canonical form the OTP is keyed by, so the code sent here
|
||||
// is findable on verify regardless of how the number was typed.
|
||||
return normalizeE164(raw) as string;
|
||||
}
|
||||
|
||||
private targetFor(channel: ContactChannel, value: string): OtpTarget {
|
||||
return channel === ContactChannel.Email ? { email: value } : { phone: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* `iam.users.email` and `.phone_number` are each independently UNIQUE, so a
|
||||
* collision would otherwise surface as a raw 500 at write time. This is a
|
||||
* courtesy check, not the guard — it races, so {@link asConflict} still has to
|
||||
* catch the violation.
|
||||
*/
|
||||
private async assertNotTaken(
|
||||
channel: ContactChannel,
|
||||
value: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.userRepository.findOne({
|
||||
where:
|
||||
channel === ContactChannel.Email
|
||||
? { email: value }
|
||||
: { phoneNumber: value },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing && existing.id !== userId) {
|
||||
throw this.takenError(channel);
|
||||
}
|
||||
}
|
||||
|
||||
private asConflict(error: unknown, channel: ContactChannel): Error {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel);
|
||||
return error as Error;
|
||||
}
|
||||
|
||||
private takenError(channel: ContactChannel): ConflictException {
|
||||
return new ConflictException(
|
||||
channel === ContactChannel.Email
|
||||
? "That email address is already registered to another account"
|
||||
: "That phone number is already registered to another account",
|
||||
);
|
||||
}
|
||||
}
|
||||
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
/** The contact channel being changed on the caller's own account. */
|
||||
export enum ContactChannel {
|
||||
Email = "email",
|
||||
Phone = "phone",
|
||||
}
|
||||
|
||||
export class SendContactOtpDto {
|
||||
@ApiProperty({ enum: ContactChannel })
|
||||
@IsEnum(ContactChannel)
|
||||
channel!: ContactChannel;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The NEW email or phone to verify. The code is sent here, not to the " +
|
||||
"address currently on the account — that is what proves the caller " +
|
||||
"controls the number/inbox they are moving to.",
|
||||
example: "+251911223344",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
value!: string;
|
||||
}
|
||||
|
||||
export class UpdateContactDto extends SendContactOtpDto {
|
||||
@ApiProperty({ description: "The 6-digit code sent to the new value" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
export class AccountNameDto {
|
||||
@ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
am!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
en?: string;
|
||||
}
|
||||
|
||||
export class UpdateAccountNameDto {
|
||||
@ApiProperty({ type: AccountNameDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => AccountNameDto)
|
||||
name!: AccountNameDto;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user
|
||||
|
||||
import { OtpService, OtpTarget } from "../otp/otp.service";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
|
||||
/**
|
||||
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
|
||||
@@ -158,12 +159,6 @@ export class ForgotPasswordService {
|
||||
|
||||
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||
maskTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
return maskOtpTarget(target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||
|
||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||
import { OtpModule } from '../otp/otp.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { CustomerResetController } from './customer-reset.controller';
|
||||
@@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
|
||||
TypeOrmModule.forFeature([
|
||||
User,
|
||||
UserVerification,
|
||||
ExternalProfile,
|
||||
Session,
|
||||
Employee,
|
||||
]),
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [
|
||||
FreightMeController,
|
||||
AccountController,
|
||||
CheckAvailabilityController,
|
||||
ForgotPasswordController,
|
||||
CustomerResetController,
|
||||
],
|
||||
providers: [
|
||||
FreightMeService,
|
||||
AccountService,
|
||||
CheckAvailabilityService,
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
|
||||
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { OtpTarget } from "../otp/otp.service";
|
||||
|
||||
/**
|
||||
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
||||
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
||||
* a caller who has not yet proven possession of the channel.
|
||||
*/
|
||||
export function maskOtpTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||
@@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
@@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||
const phone = b.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
|
||||
: null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
|
||||
@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesRepository extends BaseRepository<Company> {
|
||||
/**
|
||||
* A company still being filled in by its owner in the portal wizard: it was
|
||||
* self-registered (so it has an external profile) and nobody has submitted
|
||||
* onboarding yet. The row exists from the wizard's first click, carrying a
|
||||
* 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
|
||||
)
|
||||
)`;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
async findPaginated(
|
||||
query: ListCompaniesQueryDto,
|
||||
): Promise<{ items: Company[]; total: number }> {
|
||||
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
search,
|
||||
type,
|
||||
kind,
|
||||
status,
|
||||
onboardingCompleted,
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
|
||||
// External profiles carry onboardingCompleted, which the backoffice list
|
||||
// uses to flag customers still mid-onboarding (not yet reviewable).
|
||||
.leftJoinAndSelect('company.profiles', 'profiles')
|
||||
.where('company.deleted_at IS NULL');
|
||||
|
||||
if (type) {
|
||||
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
qb.andWhere('company.status = :status', { status });
|
||||
}
|
||||
|
||||
if (onboardingCompleted !== undefined) {
|
||||
qb.andWhere(
|
||||
onboardingCompleted
|
||||
? `NOT ${CompaniesRepository.DRAFT_SQL}`
|
||||
: CompaniesRepository.DRAFT_SQL,
|
||||
);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
qb.andWhere(
|
||||
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
}
|
||||
|
||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||
const rows: { status: string; count: string }[] = await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.select('company.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.groupBy('company.status')
|
||||
.getRawMany();
|
||||
// Drafts are counted separately rather than under `pending`: they carry
|
||||
// status=pending from creation, which would otherwise inflate the review
|
||||
// queue's KPI with customers who haven't submitted anything yet.
|
||||
const rows: { status: string; is_draft: boolean; count: string }[] =
|
||||
await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.select('company.status', 'status')
|
||||
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.groupBy('company.status')
|
||||
.addGroupBy(CompaniesRepository.DRAFT_SQL)
|
||||
.getRawMany();
|
||||
|
||||
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
|
||||
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const map = new Map<string, number>();
|
||||
let onboarding = 0;
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const count = parseInt(row.count, 10);
|
||||
total += count;
|
||||
if (row.is_draft) onboarding += count;
|
||||
else map.set(row.status, (map.get(row.status) ?? 0) + count);
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
active: map.get('active') ?? 0,
|
||||
pending: map.get('pending') ?? 0,
|
||||
onboarding,
|
||||
suspended: map.get('suspended') ?? 0,
|
||||
blacklisted: map.get('blacklisted') ?? 0,
|
||||
};
|
||||
|
||||
@@ -372,6 +372,9 @@ export class CompaniesService {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
// External profiles carry the onboarding flag the backoffice gates
|
||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||
return company;
|
||||
}
|
||||
|
||||
@@ -962,6 +965,28 @@ export class CompaniesService {
|
||||
if (!existing)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// A self-registered company is only reviewable once its owner submits the
|
||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||
// half-filled drafts and approving one would mint a reference against an
|
||||
// application that doesn't exist yet. Staff-created companies have no
|
||||
// external profiles and are exempt.
|
||||
//
|
||||
// Only the review decision itself is gated (a profile still awaiting one:
|
||||
// Pending, or Rejected and awaiting re-approval). Profiles already in
|
||||
// service stay managable so staff can suspend/blacklist them — including to
|
||||
// undo an approval granted before this guard existed.
|
||||
const awaitingReview =
|
||||
existing.status === ProfileStatus.Pending ||
|
||||
existing.status === ProfileStatus.Rejected;
|
||||
if (awaitingReview) {
|
||||
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
|
||||
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
||||
throw new BadRequestException(
|
||||
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationPriority,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
|
||||
|
||||
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||
@@ -28,11 +31,13 @@ export class CompanyNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** 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 phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
|
||||
const email = company.email ?? company.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export class CompanyStatsResponseDto {
|
||||
total!: number;
|
||||
active!: number;
|
||||
/** Submitted applications awaiting review. Excludes drafts. */
|
||||
pending!: number;
|
||||
/** Self-registered companies still working through the onboarding wizard. */
|
||||
onboarding!: number;
|
||||
suspended!: number;
|
||||
blacklisted!: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
|
||||
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyStatus))
|
||||
status?: CompanyStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Filter by onboarding submission. `true` = reviewable applications; " +
|
||||
"`false` = drafts still in the portal wizard. Omit for both.",
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
|
||||
attributes?: Record<string, any> | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
companyProfiles?: ResponseCompanyProfileDto[];
|
||||
/**
|
||||
* Whether the owning portal user has submitted the onboarding wizard.
|
||||
* Approval decisions are blocked while this is false. Staff-created
|
||||
* companies (no external profiles) count as completed. Undefined when the
|
||||
* external profiles weren't loaded.
|
||||
*/
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
|
||||
this.companyProfiles = company.companyProfiles?.map(
|
||||
(p) => new ResponseCompanyProfileDto(p),
|
||||
);
|
||||
this.onboardingCompleted = company.profiles
|
||||
? company.profiles.length === 0 ||
|
||||
company.profiles.some((p) => p.onboardingCompleted)
|
||||
: undefined;
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
@@ -24,6 +27,8 @@ export class ContractNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private ref(c: Contract): string {
|
||||
@@ -37,7 +42,9 @@ export class ContractNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
|
||||
const phone = c.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
|
||||
: null;
|
||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
@@ -102,8 +104,41 @@ export class ContractTransitionService {
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
||||
* IAM account number.
|
||||
*
|
||||
* H12(b): resolved server-side from the authenticated user id, never from the
|
||||
* request body — a caller-supplied number would let an attacker point the code
|
||||
* at their own phone. Ownership is already gated separately by
|
||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||
* signature to the *person* signing rather than to a company landline that may
|
||||
* be shared, stale, or imported from eTrade.
|
||||
*/
|
||||
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
|
||||
if (!signerUserId) {
|
||||
// Unreachable in practice (the ownership gate rejects a missing user
|
||||
// first), but never fall back to another number if it ever changes.
|
||||
throw new BadRequestException('Authentication required to sign');
|
||||
}
|
||||
const rows: Array<{ phone_number: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
[signerUserId],
|
||||
);
|
||||
const phone = rows[0]?.phone_number?.trim();
|
||||
if (!phone) {
|
||||
throw new BadRequestException(
|
||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
async submit(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -804,10 +839,10 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
|
||||
* the same number {@link sign} verifies against. The client never picks the
|
||||
* number (that is the H12(b) trust property): it only asks us to send, and we
|
||||
* resolve the phone from the contract. Returns a masked hint so the UI can
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
||||
* same number {@link sign} verifies against. The client never picks the number
|
||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
||||
* say where the code went without exposing the full number.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
@@ -823,14 +858,9 @@ export class ContractTransitionService {
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to send the signing OTP to',
|
||||
);
|
||||
}
|
||||
await this.otpService.sendOtp({ phone: companyPhone });
|
||||
return { sentTo: maskPhone(companyPhone) };
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
await this.otpService.sendOtp({ phone: signerPhone });
|
||||
return { sentTo: maskPhone(signerPhone) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
@@ -856,20 +886,18 @@ export class ContractTransitionService {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
|
||||
// registered phone — never the caller-supplied dto.otpPhone, which an
|
||||
// attacker could point at their own phone to sign someone else's
|
||||
// contract. The OTP is issued to the company's registered number.
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to verify the signing OTP against',
|
||||
);
|
||||
}
|
||||
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||
// phone, resolved server-side from the authenticated user id — never a
|
||||
// caller-supplied number, which an attacker could point at their own
|
||||
// phone. Ownership is already asserted above, so this proves the specific
|
||||
// person holding the account is present, not merely that someone reached a
|
||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
||||
// and verify would target different numbers.
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -28,17 +28,13 @@ export class SignContractDto {
|
||||
consentText?: string;
|
||||
|
||||
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
|
||||
// SMS'd to the signer's phone, verified server-side before the signature is
|
||||
// applied. `otpPhone` is the number the code was sent to (the signed-in
|
||||
// customer's registered phone).
|
||||
// SMS'd to the signer's registered phone, verified server-side before the
|
||||
// signature is applied. The number itself is deliberately NOT part of this
|
||||
// DTO — the server resolves it from the authenticated user id, so a caller
|
||||
// cannot redirect the challenge to a phone they control.
|
||||
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
|
||||
otp?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
otpPhone?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import {
|
||||
companyNotifyPhoneExpr,
|
||||
primaryContactUserJoin,
|
||||
} from './resolve-company-phone.util';
|
||||
|
||||
/**
|
||||
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||
@@ -15,9 +19,10 @@ export async function sendCompanyChannels(
|
||||
): Promise<void> {
|
||||
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
||||
FROM freight.companies
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
`SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email
|
||||
FROM freight.companies co
|
||||
${primaryContactUserJoin('co')}
|
||||
WHERE co.id = $1 AND co.deleted_at IS NULL`,
|
||||
[companyId],
|
||||
);
|
||||
if (contact?.phone) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
|
||||
/**
|
||||
* Where a customer-facing SMS actually goes.
|
||||
*
|
||||
* The person who signs up, logs in, and receives OTPs is an IAM user, and
|
||||
* `iam.users.phone_number` is the number they control and can change themselves
|
||||
* (see the account settings flow). A company's own `phone` is business contact
|
||||
* data — often a landline, a shared desk, or a stale eTrade import — so it is
|
||||
* the fallback, not the source.
|
||||
*
|
||||
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
|
||||
* path stores that value in the `attributes` jsonb and has never populated the
|
||||
* column, so every reader of it was silently falling through to `phone` anyway.
|
||||
*/
|
||||
|
||||
/**
|
||||
* LEFT JOIN a company alias to its primary contact's IAM user, exposing
|
||||
* `pc.phone_number`.
|
||||
*
|
||||
* LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a
|
||||
* company having two `is_primary_contact` rows, and a plain join would then
|
||||
* duplicate the company row — which in a fan-out query means sending the same
|
||||
* customer the same SMS twice.
|
||||
*
|
||||
* `alias` is always a code-controlled literal, never caller input.
|
||||
*/
|
||||
export function primaryContactUserJoin(alias: string): string {
|
||||
return `
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT u.phone_number
|
||||
FROM freight.external_profiles ep
|
||||
JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true
|
||||
WHERE ep.company_id = ${alias}.id
|
||||
AND ep.is_primary_contact = true
|
||||
AND ep.deleted_at IS NULL
|
||||
ORDER BY ep.created_at
|
||||
LIMIT 1
|
||||
) pc ON true`;
|
||||
}
|
||||
|
||||
/** SQL expression for the company's SMS number, given the joined `pc` alias. */
|
||||
export function companyNotifyPhoneExpr(alias: string): string {
|
||||
return `COALESCE(pc.phone_number, ${alias}.phone)`;
|
||||
}
|
||||
|
||||
/** The SMS number for one company, or null when neither source has one. */
|
||||
export async function resolveCompanyNotifyPhone(
|
||||
db: DataSource | EntityManager,
|
||||
companyId: string,
|
||||
): Promise<string | null> {
|
||||
const rows: Array<{ phone: string | null }> = await db.query(
|
||||
`SELECT ${companyNotifyPhoneExpr("co")} AS phone
|
||||
FROM freight.companies co
|
||||
${primaryContactUserJoin("co")}
|
||||
WHERE co.id = $1 AND co.deleted_at IS NULL`,
|
||||
[companyId],
|
||||
);
|
||||
return rows[0]?.phone ?? null;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationPriority,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
@@ -20,6 +23,8 @@ export class BookingNotifierService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly trainSchedules: TrainSchedulesRepository,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -60,7 +65,9 @@ export class BookingNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||
const phone = b.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
|
||||
: null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
|
||||
@@ -13,6 +13,10 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import {
|
||||
companyNotifyPhoneExpr,
|
||||
primaryContactUserJoin,
|
||||
} from '../notifications/resolve-company-phone.util';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||
@@ -527,7 +531,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT DISTINCT
|
||||
c.company_id,
|
||||
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
||||
${companyNotifyPhoneExpr('co')} AS phone,
|
||||
COALESCE(co.email, co.general_manager_email) AS email
|
||||
FROM freight.contract_routes cr
|
||||
JOIN freight.contracts c
|
||||
@@ -535,6 +539,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.companies co ON co.id = c.company_id
|
||||
${primaryContactUserJoin('co')}
|
||||
WHERE cr.origin_yard_id = $1
|
||||
AND cr.destination_yard_id = $2
|
||||
AND cr.deleted_at IS NULL`,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
@@ -2037,6 +2038,58 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
|
||||
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
|
||||
* loaded onto the wagons allocated to the booking, so anything still in the
|
||||
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
|
||||
* booking has warehouse inventory that never made it onto a wagon (received /
|
||||
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
|
||||
* or drop the booking's wagon allocation so it rides a later train.
|
||||
*
|
||||
* Import/domestic are untouched: their cargo isn't loaded out of an origin
|
||||
* warehouse, so warehouse inventory says nothing about what's aboard.
|
||||
*
|
||||
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
|
||||
* wagon before the goods arrive is normal planning; they simply aren't aboard.
|
||||
*/
|
||||
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
|
||||
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!route) return;
|
||||
const direction = deriveTradeDirection(
|
||||
{ country: route.originCountry },
|
||||
{ country: route.destinationCountry },
|
||||
);
|
||||
if (direction !== 'EXPORT') return;
|
||||
|
||||
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = $1
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
|
||||
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -2046,6 +2099,8 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
// Export only: don't leave received cargo behind in the warehouse.
|
||||
await this.assertAllocatedCargoLoaded(scheduleId);
|
||||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Company } from '../companies/entities/company.entity';
|
||||
@@ -13,6 +14,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import {
|
||||
companyNotifyPhoneExpr,
|
||||
primaryContactUserJoin,
|
||||
} from '../notifications/resolve-company-phone.util';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
@@ -1027,6 +1032,8 @@ export class WarehouseInventoryService {
|
||||
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
|
||||
continue;
|
||||
}
|
||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads
|
||||
// onto a train without one. Import GRN handling is left untouched.
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
@@ -1036,6 +1043,9 @@ export class WarehouseInventoryService {
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
...(booking.tradeDirection === 'EXPORT'
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
|
||||
: {}),
|
||||
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||||
});
|
||||
result.processedCount += 1;
|
||||
@@ -1056,6 +1066,14 @@ export class WarehouseInventoryService {
|
||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
||||
// a train without one. Import GRN handling is left untouched.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
||||
|
||||
let location: DefaultLocation | null =
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
@@ -1076,6 +1094,10 @@ export class WarehouseInventoryService {
|
||||
zoneId: location.zoneId,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
// Export only, and keep an already-issued GRN rather than reissuing.
|
||||
...(isExport && !existing[0].grnNumber
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(existing[0].id);
|
||||
@@ -1090,6 +1112,9 @@ export class WarehouseInventoryService {
|
||||
weight: 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
...(isExport
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
notes: dto.notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(saved.id);
|
||||
@@ -1145,7 +1170,7 @@ export class WarehouseInventoryService {
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||||
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
|
||||
bcu.seal_numbers AS "sealNumbers",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
@@ -1183,6 +1208,7 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
@@ -1288,7 +1314,7 @@ export class WarehouseInventoryService {
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
@@ -1317,6 +1343,7 @@ export class WarehouseInventoryService {
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
@@ -1536,11 +1563,19 @@ export class WarehouseInventoryService {
|
||||
// their already-allocated wagons. Reuses the single-item load() machinery.
|
||||
|
||||
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
|
||||
/**
|
||||
* Export flow this queue serves: booked -> paid -> received at the warehouse
|
||||
* (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
|
||||
* booking. Which bookings ride a train comes from the shared CTE.
|
||||
*/
|
||||
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
|
||||
|
||||
async loadableTrains(): Promise<LoadableTrainRow[]> {
|
||||
const rows: Array<
|
||||
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS "scheduleId",
|
||||
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT ts.id AS "scheduleId",
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
@@ -1548,15 +1583,15 @@ export class WarehouseInventoryService {
|
||||
dy.country AS "destinationCountry",
|
||||
ts.status AS "status",
|
||||
ts.scheduled_departure_date AS "departureTime",
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||
(SELECT count(*) FROM sched_bookings sb
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = ts.id
|
||||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
|
||||
(SELECT count(*) FROM sched_bookings sb
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
||||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = ts.id
|
||||
AND inv.status = 'LOADED') AS "loadedCount"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
@@ -1564,11 +1599,11 @@ export class WarehouseInventoryService {
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status = ANY($1)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.train_schedule_bookings tsb2
|
||||
SELECT 1 FROM sched_bookings sb2
|
||||
JOIN freight.warehouse_inventory inv2
|
||||
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
|
||||
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
|
||||
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||
ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
|
||||
WHERE sb2.schedule_id = ts.id
|
||||
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||
)
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
[['DRAFT', 'SCHEDULED']],
|
||||
@@ -1593,22 +1628,28 @@ export class WarehouseInventoryService {
|
||||
*/
|
||||
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
|
||||
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
|
||||
`SELECT inv.id AS "id",
|
||||
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT inv.id AS "id",
|
||||
inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
ct.container_number AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
inv.weight AS "weight",
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
|
||||
-- receive() stamps the GRN onto the row and mirrors it into the
|
||||
-- note; prefer the column and fall back for legacy/seeded rows.
|
||||
COALESCE(
|
||||
inv.grn_number,
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) AS "grnNumber",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
inv.status AS "status",
|
||||
wl.wagon_id AS "wagonId",
|
||||
wl.wagon_number AS "wagonNumber",
|
||||
wl.sequence_no AS "sequenceNo"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
@@ -1625,15 +1666,19 @@ export class WarehouseInventoryService {
|
||||
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||||
LIMIT 1
|
||||
) wl ON true
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||
WHERE sb.schedule_id = $1
|
||||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
|
||||
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
|
||||
// The row only exists once the goods were received, so requiring a GRN and
|
||||
// an allocated wagon completes the chain.
|
||||
loadable:
|
||||
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1686,6 +1731,9 @@ export class WarehouseInventoryService {
|
||||
if (!item) { skip('Not assigned to this train'); continue; }
|
||||
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
|
||||
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
|
||||
// Export: the GRN is raised when the goods arrive at the warehouse, and
|
||||
// nothing rides a train without one.
|
||||
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
|
||||
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
||||
|
||||
try {
|
||||
@@ -4946,7 +4994,7 @@ export class WarehouseInventoryService {
|
||||
`SELECT b.reference AS "reference",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
@@ -4963,6 +5011,7 @@ export class WarehouseInventoryService {
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
InvoiceDocumentService,
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import {
|
||||
companyNotifyPhoneExpr,
|
||||
primaryContactUserJoin,
|
||||
} from "../notifications/resolve-company-phone.util";
|
||||
import { WarehouseFeeService } from "./warehouse-fee.service";
|
||||
import {
|
||||
WarehouseFeeInvoiceView,
|
||||
@@ -879,7 +883,7 @@ export class WarehouseInvoiceService {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
|
||||
last_vehicle.assigned_driver_name,
|
||||
@@ -892,6 +896,7 @@ export class WarehouseInvoiceService {
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
|
||||
Reference in New Issue
Block a user