mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
fix(companies): honest status errors and per-role suspension visibility
A suspended customer trying to create a contract was told their account was "awaiting approval" - the guards collapsed every non-active status into the pending message. Contract and booking creation now go through a shared assertCompanyActiveFor that names the real company status, and the per-role guard names the role's actual state (suspended, blacklisted, rejected - with the reviewer note) so a customer suspended for one operation knows the others still work. Portal: the operation dropdown gets a Suspended badge, the block modal a suspended branch quoting the staff message, and the wizard a suspended/blacklisted gate instead of falling through to a submit error. EDRFREIGHT-234
This commit is contained in:
@@ -635,12 +635,9 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
// A customer can only book once their company has been approved.
|
// A customer can only book once their company has been approved; the
|
||||||
if (company.status !== CompanyStatus.Active) {
|
// helper names the real status (suspended/blacklisted) when it isn't.
|
||||||
throw new ForbiddenException(
|
this.companiesService.assertCompanyActiveFor(company, 'bookings');
|
||||||
"Your company is awaiting approval — you can't create bookings yet.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1649,21 +1649,68 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block a customer from booking under a profile that isn't approved yet.
|
* Block a self-service action when the company account isn't active, naming
|
||||||
* Called from the booking-create path for self-service bookings; staff- and
|
* the actual status — a suspended customer told "awaiting approval" has no
|
||||||
* government-initiated bookings bypass this. No-op when the profile can't be
|
* idea what happened or who to call.
|
||||||
* found (defensive — resolution is best-effort upstream).
|
*/
|
||||||
|
assertCompanyActiveFor(company: Company, action: string): void {
|
||||||
|
if (company.status === CompanyStatus.Active) return;
|
||||||
|
switch (company.status) {
|
||||||
|
case CompanyStatus.Suspended:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company account is suspended — you can't create ${action} right now. ` +
|
||||||
|
`Please contact EDR support for details.`,
|
||||||
|
);
|
||||||
|
case CompanyStatus.Blacklisted:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||||
|
`Please contact EDR support.`,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company is awaiting approval — you can't create ${action} yet.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block a customer from booking under a profile that isn't approved yet — or
|
||||||
|
* that a reviewer has since suspended. Called from the booking/contract
|
||||||
|
* create path for self-service actions; staff- and government-initiated ones
|
||||||
|
* bypass this. No-op when the profile can't be found (defensive — resolution
|
||||||
|
* is best-effort upstream). The message names the profile's real status:
|
||||||
|
* suspension in particular is per-role, so the customer must learn which
|
||||||
|
* operation is blocked (their other roles still work).
|
||||||
*/
|
*/
|
||||||
async assertCompanyProfileApprovedForBooking(
|
async assertCompanyProfileApprovedForBooking(
|
||||||
companyProfileId: string,
|
companyProfileId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
if (profile.status !== ProfileStatus.Active) {
|
if (profile.status === ProfileStatus.Active) return;
|
||||||
const role = profile.type.replace(/_/g, " ");
|
|
||||||
throw new ForbiddenException(
|
const role = profile.type.replace(/_/g, " ");
|
||||||
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
switch (profile.status) {
|
||||||
);
|
case ProfileStatus.Suspended:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role is suspended${
|
||||||
|
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
|
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||||
|
);
|
||||||
|
case ProfileStatus.Blacklisted:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role is blacklisted. Please contact EDR support.`,
|
||||||
|
);
|
||||||
|
case ProfileStatus.Rejected:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role was rejected${
|
||||||
|
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
|
}. Amend and resubmit it from your settings page.`,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { YardCountry } from '@edr/types';
|
|||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -181,11 +180,7 @@ export class ContractsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
if (company.status !== CompanyStatus.Active) {
|
this.companiesService.assertCompanyActiveFor(company, 'contracts');
|
||||||
throw new ForbiddenException(
|
|
||||||
"Your company is awaiting approval — you can't create contracts yet.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,30 @@ export default function NewContractPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A suspended/blacklisted account must be told its real status — falling
|
||||||
|
// through to the wizard here only to fail at submit read as "pending" before.
|
||||||
|
if (
|
||||||
|
!auth.isPending &&
|
||||||
|
(auth.companyStatus === "suspended" || auth.companyStatus === "blacklisted")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<GateNotice
|
||||||
|
title={
|
||||||
|
auth.companyStatus === "suspended"
|
||||||
|
? "Account Suspended"
|
||||||
|
: "Account Blacklisted"
|
||||||
|
}
|
||||||
|
body={
|
||||||
|
auth.companyStatus === "suspended"
|
||||||
|
? "Your company account has been suspended by EDR staff, so new contracts are disabled. Please contact EDR support for details."
|
||||||
|
: "Your company account has been blacklisted, so new contracts are disabled. Please contact EDR support."
|
||||||
|
}
|
||||||
|
actionLabel="Back to Contracts"
|
||||||
|
onAction={() => navigate("/contracts")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Profile changes pending review lock out new contract creation too.
|
// Profile changes pending review lock out new contract creation too.
|
||||||
if (!auth.isPending && auth.isUnderReview) {
|
if (!auth.isPending && auth.isUnderReview) {
|
||||||
return (
|
return (
|
||||||
@@ -349,13 +373,19 @@ export default function NewContractPage({
|
|||||||
// badges. Intercity rides any customer profile, so always "approved".
|
// badges. Intercity rides any customer profile, so always "approved".
|
||||||
const operationStatus = useMemo(
|
const operationStatus = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
|
(
|
||||||
|
op: OperationType,
|
||||||
|
): "approved" | "pending" | "rejected" | "suspended" | "missing" => {
|
||||||
if (op === "intercity") return "approved";
|
if (op === "intercity") return "approved";
|
||||||
const target = operationToProfileType(op, profileTypes);
|
const target = operationToProfileType(op, profileTypes);
|
||||||
const status = profileStatusByType.get(target);
|
const status = profileStatusByType.get(target);
|
||||||
if (!status) return "missing";
|
if (!status) return "missing";
|
||||||
if (status === "active") return "approved";
|
if (status === "active") return "approved";
|
||||||
if (status === "rejected") return "rejected";
|
if (status === "rejected") return "rejected";
|
||||||
|
// Suspension is per-role: the customer keeps working under their other
|
||||||
|
// roles, so this operation must say "suspended", not "pending".
|
||||||
|
if (status === "suspended" || status === "blacklisted")
|
||||||
|
return "suspended";
|
||||||
return "pending";
|
return "pending";
|
||||||
},
|
},
|
||||||
[profileStatusByType, profileTypes],
|
[profileStatusByType, profileTypes],
|
||||||
@@ -1171,6 +1201,8 @@ export default function NewContractPage({
|
|||||||
? profileByType.get(pendingApprovalProfile)
|
? profileByType.get(pendingApprovalProfile)
|
||||||
: undefined;
|
: undefined;
|
||||||
const isRejected = target?.status === "rejected";
|
const isRejected = target?.status === "rejected";
|
||||||
|
const isSuspended =
|
||||||
|
target?.status === "suspended" || target?.status === "blacklisted";
|
||||||
const label = pendingApprovalProfile
|
const label = pendingApprovalProfile
|
||||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||||
pendingApprovalProfile)
|
pendingApprovalProfile)
|
||||||
@@ -1179,12 +1211,42 @@ export default function NewContractPage({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={pendingApprovalProfile !== null}
|
opened={pendingApprovalProfile !== null}
|
||||||
onClose={() => setPendingApprovalProfile(null)}
|
onClose={() => setPendingApprovalProfile(null)}
|
||||||
title={isRejected ? "Profile not approved" : "Awaiting approval"}
|
title={
|
||||||
|
isRejected
|
||||||
|
? "Profile not approved"
|
||||||
|
: isSuspended
|
||||||
|
? "Role suspended"
|
||||||
|
: "Awaiting approval"
|
||||||
|
}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{isRejected ? (
|
{isSuspended ? (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Your {label} role has been suspended by EDR staff, so you
|
||||||
|
can't start a contract under it. Your other roles are
|
||||||
|
unaffected. Contact EDR support to resolve this.
|
||||||
|
</Text>
|
||||||
|
{target?.reviewNote && (
|
||||||
|
<Alert color="orange" variant="light" radius="md">
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Message from EDR staff:</strong>{" "}
|
||||||
|
{target.reviewNote}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
onClick={() => setPendingApprovalProfile(null)}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : isRejected ? (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Your {label} profile was not approved. Fix the issue below
|
Your {label} profile was not approved. Fix the issue below
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function Step1ContractType({
|
|||||||
/** Approval state of the profile each operation maps to (for the badges). */
|
/** Approval state of the profile each operation maps to (for the badges). */
|
||||||
operationStatus?: (
|
operationStatus?: (
|
||||||
op: OperationType,
|
op: OperationType,
|
||||||
) => "approved" | "pending" | "rejected" | "missing";
|
) => "approved" | "pending" | "rejected" | "suspended" | "missing";
|
||||||
}) {
|
}) {
|
||||||
const contractType = form.watch("contractType");
|
const contractType = form.watch("contractType");
|
||||||
|
|
||||||
@@ -229,6 +229,11 @@ export function Step1ContractType({
|
|||||||
Rejected
|
Rejected
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{status === "suspended" && (
|
||||||
|
<Badge size="xs" color="orange" variant="light" radius="sm">
|
||||||
|
Suspended
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{status === "missing" && (
|
{status === "missing" && (
|
||||||
<Badge size="xs" color="gray" variant="light" radius="sm">
|
<Badge size="xs" color="gray" variant="light" radius="sm">
|
||||||
Add license
|
Add license
|
||||||
|
|||||||
Reference in New Issue
Block a user