diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 897a7b764..5b333f2b9 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -37,7 +37,7 @@ "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", diff --git a/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts b/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts new file mode 100644 index 000000000..bc1765cfe --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Company-profile references are now minted only when a profile is approved + * (status → Active); pending profiles carry NULL. Drop the NOT NULL constraint + * on freight.company_profiles.reference. The existing unique index is kept — + * Postgres treats NULLs as distinct, so multiple pending (NULL) profiles don't + * collide. + */ +export class MakeCompanyProfileReferenceNullable1810000000002 + implements MigrationInterface +{ + name = "MakeCompanyProfileReferenceNullable1810000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" DROP NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reinstating NOT NULL requires every row to have a reference; any pending + // (NULL) profiles get a placeholder so the constraint can be re-applied. + await queryRunner.query( + `UPDATE "freight"."company_profiles" SET "reference" = 'PENDING-' || left(replace("id"::text, '-', ''), 12) WHERE "reference" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" SET NOT NULL`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts new file mode 100644 index 000000000..ec0610f52 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +/** + * Create the public.otp_verifications table backing the OTP module + * (OtpVerification entity). One row per phone, holding the latest server-issued + * code and whether that phone has been verified. + */ +export class CreateOtpVerifications1810000000003 + implements MigrationInterface +{ + name = "CreateOtpVerifications1810000000003"; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("otp_verifications"); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: "otp_verifications", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + default: "gen_random_uuid()", + }, + { name: "phone", type: "varchar", isUnique: true }, + { name: "otp", type: "varchar" }, + { name: "verified", type: "boolean", default: false }, + { name: "created_at", type: "timestamptz", default: "now()" }, + { name: "updated_at", type: "timestamptz", default: "now()" }, + { name: "deleted_at", type: "timestamptz", isNullable: true }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("otp_verifications", true); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 45d2fbf7c..78555c57f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -371,6 +371,17 @@ export class BookingsService { tradeDirection, fallbackType, ); + + // A customer booking under their own account may only do so once the + // resolved operational profile has been approved by the backoffice. Staff- + // and government-initiated bookings (companyId supplied explicitly) bypass + // this gate. + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } const needsConsolidation = diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 2fba1e878..4bcc3252a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -226,6 +227,17 @@ export class CompaniesController { await this.companiesService.setOnboardingStep(user.id, dto.step); } + @Get("onboarding/requirements") + @ApiOperation({ + summary: + "What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)", + }) + async getOnboardingRequirements( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.getOnboardingRequirements(user.id); + } + @Post("onboarding/complete") @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) async completeOnboarding( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index d275c57a7..88871f8ad 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; +import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; import { MinioModule } from "../minio/minio.module"; import { CompaniesController } from "./companies.controller"; import { CompaniesService } from "./companies.service"; @@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service"; TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), HttpModule, FilesModule, + FileUploadSettingsModule, MinioModule, ], controllers: [CompaniesController], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 9c1e290be..7858945e2 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, ConflictException, BadRequestException, + ForbiddenException, } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; @@ -12,7 +13,10 @@ import { DashboardScope, } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; +import { FilesService } from "../files/files.service"; +import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; @@ -53,9 +57,67 @@ export class CompaniesService { private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, ) { } + /** + * Required company-information fields that must be filled before onboarding can + * be submitted. The backend owns this list so the portal never has to know + * which fields are mandatory — it just renders what's reported outstanding. + * `get` reads the value from the company (some live in the attributes blob). + */ + private readonly REQUIRED_COMPANY_INFO: { + key: string; + label: string; + get: (company: Company) => unknown; + }[] = [ + { + key: "tinNumber", + label: "Company TIN", + get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), + }, + { key: "companyEmail", label: "Company email", get: (c) => c.email }, + { key: "companyPhone", label: "Company phone", get: (c) => c.phone }, + { key: "companyAddress", label: "Company address", get: (c) => c.address }, + { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, + { + key: "contactPersonName", + label: "Contact person name", + get: (c) => c.attributes?.contactPersonName, + }, + { + key: "contactPersonPhone", + label: "Contact person phone", + get: (c) => c.attributes?.contactPersonPhone, + }, + { + key: "generalManagerName", + label: "General manager name", + get: (c) => c.attributes?.generalManagerName, + }, + { + key: "generalManagerEmail", + label: "General manager email", + get: (c) => c.attributes?.generalManagerEmail, + }, + { + key: "generalManagerPhone", + label: "General manager phone", + get: (c) => c.attributes?.generalManagerPhone, + }, + ]; + + /** The nationality-based document setting code for a company. */ + private documentSettingCodeFor( + nationality: CompanyNationality | null | undefined, + ): string { + return nationality === CompanyNationality.Foreign + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; + } + async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { @@ -134,15 +196,13 @@ export class CompaniesService { input.type, ); if (existing) continue; - const reference = await this.companyProfilesRepo.generateReference( - input.type, - ); + // No reference yet — these profiles await backoffice approval, which + // is when the reference is minted (see setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId: company.id, type: input.type, - reference, businessLicense: input.businessLicense ?? null, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( @@ -251,12 +311,11 @@ export class CompaniesService { type, ); if (existing) continue; - const reference = await this.companyProfilesRepo.generateReference(type); + // No reference yet — minted on backoffice approval (setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } } @@ -527,6 +586,8 @@ export class CompaniesService { attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); + if (dto.contactVerifiedPhone !== undefined) + attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone); if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; if (dto.generalManagerEmail !== undefined) @@ -622,12 +683,33 @@ export class CompaniesService { profileId: string, status: ProfileStatus, ): Promise { - const updated = await this.companyProfilesRepo.updateStatus( - profileId, - status, - ); + const existing = await this.companyProfilesRepo.findById(profileId); + if (!existing) + throw new NotFoundException(`Company profile ${profileId} not found`); + + // A reference number is only minted the first time a profile is approved + // (status → Active). Pending/unapproved profiles carry no reference. + const patch: Partial = { status }; + if (status === ProfileStatus.Active && !existing.reference) { + patch.reference = await this.companyProfilesRepo.generateReference( + existing.type, + ); + } + + const updated = await this.companyProfilesRepo.update(profileId, patch); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // Approving any profile promotes a pending company to active, so the + // customer can start working as soon as their first profile is cleared. + if (status === ProfileStatus.Active) { + const company = await this.companiesRepo.findById(updated.companyId); + if (company && company.status === CompanyStatus.Pending) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + } + } return updated; } @@ -649,7 +731,7 @@ export class CompaniesService { const existing = await this.companyProfilesRepo.findByType(companyId, type); if (existing) { throw new ConflictException( - `Company already has a ${type} profile (${existing.reference})`, + `Company already has a ${type} profile (${existing.reference ?? "pending approval"})`, ); } @@ -813,6 +895,100 @@ export class CompaniesService { await this.profilesRepo.update(profile.id, { onboardingStep: step }); } + /** + * Server-driven onboarding requirements for the current user's company. + * + * The backend resolves the nationality-based document set, checks which + * company documents and per-profile licenses are already uploaded, and reports + * exactly what is still outstanding. The portal renders this list verbatim and + * relies on `isComplete` to decide when to auto-finish — it never decides for + * itself which documents apply or which fields are mandatory. + */ + async getOnboardingRequirements( + userId: string, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + // 1. Required company-information fields. + const missingInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => !f.get(company), + ).map((f) => ({ key: f.key, label: f.label })); + + // 2. Nationality-based company documents + which are already uploaded. + const documentSettingCode = this.documentSettingCodeFor(company.nationality); + const [setting, uploadedFiles] = await Promise.all([ + this.fileUploadSettingsService + .getByCode(documentSettingCode) + .catch(() => null), + this.filesService.findByResource(company.id, "companies"), + ]); + const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); + const documents = (setting?.fields ?? []) + .slice() + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((f) => ({ + fileKey: f.fileKey, + fileLabel: f.fileLabel, + helpText: f.helpText ?? null, + isRequired: f.isRequired, + isMultiple: f.isMultiple, + maxFiles: f.maxFiles, + allowedExtensions: f.allowedExtensions, + maxSizeMb: f.maxSizeMb, + displayOrder: f.displayOrder, + uploaded: uploadedCodes.has(f.fileKey), + })); + const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); + + // 3. Per-operational-profile business licenses. + const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ + profileId: p.id, + type: p.type, + reference: p.reference ?? "", + uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, + })); + const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + + const outstanding = [ + ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), + ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), + ...missingLicenses.map( + (p) => + `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, + ), + ]; + + // Progress spans every required item the user has to satisfy: company-info + // fields, required documents and one license per operational profile. + const requiredDocCount = documents.filter((d) => d.isRequired).length; + const total = + this.REQUIRED_COMPANY_INFO.length + + requiredDocCount + + licenseProfiles.length; + const completed = + total - + (missingInfo.length + missingDocs.length + missingLicenses.length); + + return new OnboardingRequirementsResponseDto({ + documentSettingCode, + nationality: company.nationality ?? CompanyNationality.Ethiopian, + companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, + documents, + licenseProfiles, + progress: { completed, total }, + isComplete: outstanding.length === 0, + onboardingCompleted: profile.onboardingCompleted, + outstanding, + }); + } + + /** + * Submit onboarding for review. Validation is delegated entirely to + * getOnboardingRequirements (the same source of truth the portal renders), so + * the gate can never drift from what the UI shows. On success the company and + * all its operational profiles move to PENDING — the backoffice approves each + * profile before it can be used (see setCompanyProfileStatus). + */ async markOnboardingComplete( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { @@ -821,23 +997,21 @@ export class CompaniesService { throw new NotFoundException(`Profile for user ${userId} not found`); const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - // Guard against finishing on a still-draft company (TIN never filled in). - if (!company.tin || company.tin.startsWith("D")) { + const requirements = await this.getOnboardingRequirements(userId); + if (!requirements.isComplete) { throw new BadRequestException( - "Company information is incomplete — please fill in your company details before finishing.", + requirements.outstanding[0] ?? + "Your onboarding is incomplete. Please complete all required steps before submitting.", ); } - // Every operational profile must have at least one business-license file - // (stored directly on the profile). + // Send every operational profile in for approval; the company itself becomes + // active once the backoffice approves at least one profile. const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); for (const cp of profiles) { - if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) { - throw new BadRequestException( - `Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`, - ); + if (cp.status !== ProfileStatus.Pending) { + await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending); } } @@ -852,6 +1026,25 @@ export class CompaniesService { return this.getCompanyInfoByUserId(userId); } + /** + * Block a customer from booking under a profile that isn't approved yet. + * Called from the booking-create path for self-service bookings; staff- and + * government-initiated bookings bypass this. No-op when the profile can't be + * found (defensive — resolution is best-effort upstream). + */ + async assertCompanyProfileApprovedForBooking( + companyProfileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(companyProfileId); + if (!profile) return; + if (profile.status !== ProfileStatus.Active) { + const role = profile.type.replace(/_/g, " "); + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, + ); + } + } + /** * Authorize and resolve a company_profile that must belong to the current * user's company — used before accepting/returning its license files. diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index bc2d95224..15aec5ac6 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository { } async generateReference(type: ProfileType): Promise { - const seqName = SEQUENCE_MAP[type]; + // The sequences live in the same schema as the entity (e.g. "freight"), but + // the connection's search_path is "public" — so the sequence MUST be + // schema-qualified or `nextval` fails with "relation does not exist". + const schema = this.repository.metadata.schema ?? "public"; + const seqName = `"${schema}".${SEQUENCE_MAP[type]}`; const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts new file mode 100644 index 000000000..92f9fa513 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -0,0 +1,78 @@ +/** + * Server-driven description of what a company still needs to finish onboarding. + * + * The portal renders this verbatim instead of deciding for itself which + * documents apply or which fields are mandatory: the backend resolves the + * nationality-based document set, checks which files are already uploaded, and + * reports exactly what is outstanding. `isComplete` is the single source of + * truth the wizard uses to auto-finish. + */ + +export interface OnboardingInfoField { + key: string; + label: string; +} + +export interface OnboardingDocumentField { + fileKey: string; + fileLabel: string; + helpText: string | null; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; + /** True when a file with this code is already stored for the company. */ + uploaded: boolean; +} + +export interface OnboardingLicenseProfile { + profileId: string; + type: string; + reference: string; + /** True when at least one business-license file is stored on the profile. */ + uploaded: boolean; +} + +export class OnboardingRequirementsResponseDto { + /** Resolved document setting code (by nationality) the docs were drawn from. */ + documentSettingCode: string; + nationality: string; + + /** Required company-information fields and whether each is filled. */ + companyInfo: { + complete: boolean; + missingFields: OnboardingInfoField[]; + }; + + /** The document fields the portal should render, with upload state. */ + documents: OnboardingDocumentField[]; + + /** Per-operational-profile business-license requirements. */ + licenseProfiles: OnboardingLicenseProfile[]; + + /** Overall setup progress across fields + documents + licenses. */ + progress: { completed: number; total: number }; + + /** True once every required field, document and license is satisfied. */ + isComplete: boolean; + + /** Whether the user has already submitted onboarding (awaiting approval). */ + onboardingCompleted: boolean; + + /** Human-readable list of everything still outstanding (empty when complete). */ + outstanding: string[]; + + constructor(init: Omit) { + this.documentSettingCode = init.documentSettingCode; + this.nationality = init.nationality; + this.companyInfo = init.companyInfo; + this.documents = init.documents; + this.licenseProfiles = init.licenseProfiles; + this.progress = init.progress; + this.isComplete = init.isComplete; + this.onboardingCompleted = init.onboardingCompleted; + this.outstanding = init.outstanding; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 97f2d9f50..89a52b5e6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -34,6 +34,8 @@ export class ProfileResponseDto { contactPersonPosition: string | null; contactPersonEmail: string | null; contactPersonPhone: string | null; + /** Phone that passed SMS OTP verification (drives the verify-step resume). */ + contactVerifiedPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; generalManagerPhone: string | null; @@ -81,6 +83,7 @@ export class ProfileResponseDto { this.contactPersonPosition = attrs.contactPersonPosition ?? null; this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null; + this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null; this.generalManagerName = attrs.generalManagerName ?? null; this.generalManagerEmail = attrs.generalManagerEmail ?? null; this.generalManagerPhone = attrs.generalManagerPhone ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index b62182968..5d90d8d60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto { this.id = profile.id; this.companyId = profile.companyId; this.type = profile.type; - this.reference = profile.reference; + this.reference = profile.reference ?? ''; this.status = profile.status; this.businessLicense = profile.businessLicense; this.licenseFiles = profile.businessLicenseFiles ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index c99ef9d3c..316038dc9 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -67,6 +67,16 @@ export class UpdateProfileDto { @IsValidPhone() contactPersonPhone?: string; + /** + * The contact-person phone that completed SMS OTP verification. Persisted so + * the onboarding "verify" step can resume its "done" state after a refresh + * (compared against the current contactPersonPhone on the client). + */ + @IsOptional() + @IsString() + @IsValidPhone() + contactVerifiedPhone?: string; + @IsOptional() @IsString() generalManagerName?: string; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index c0cb41a63..e61668a07 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity { @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType }) type!: ProfileType; + /** + * Official profile reference (e.g. "EX-00001"). Minted only when the profile + * is approved (status → Active); pending/unapproved profiles carry NULL. + * The unique index tolerates this because Postgres treats NULLs as distinct. + * API responses surface it as "" when absent — see ResponseCompanyProfileDto. + */ @Column({ name: "reference", type: "varchar", length: 20, - nullable: false, - unique: true, + nullable: true, }) - reference!: string; + reference!: string | null; @Column({ name: "status", diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 9866ca570..5850cbb1a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -24,13 +24,9 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string, - @Body("otp") - otp: string + phone: string ) { - return this.otpService.sendOtp( - phone,otp - ); + return this.otpService.sendOtp(phone); } // --------------------------------------------------------------------------- diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 4e16be20a..d70bc0ce8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -29,11 +29,12 @@ export class OtpService { // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string, otp: string) { + async sendOtp(phone: string) { try { - // generate otp - // const otp = - // this.generateOtp(); + // The verification code is generated server-side — never supplied by the + // caller — so the OTP stays a secret known only to the server and the + // recipient of the SMS. + const otp = this.generateOtp(); // find existing phone const existingPhone = diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index e06d310f4..aff353055 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -1,11 +1,14 @@ import { ActionIcon, + Badge, Box, Card, Group, + SegmentedControl, Stack, Text, TextInput, + Tooltip, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; @@ -32,7 +35,7 @@ import { } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; -import type { Company } from "@/types/customer"; +import type { Company, CompanyStatus } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -45,14 +48,17 @@ export default function CustomersPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); + // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). + const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, + status: statusFilter || undefined, }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -107,7 +113,25 @@ export default function CustomersPage() { { id: "status", header: "Status", - cell: ({ row }) => , + cell: ({ row }) => { + const pending = (row.original.companyProfiles ?? []).filter( + (p) => p.status === "pending", + ).length; + return ( + + + {pending > 0 ? ( + 1 ? "s" : ""} awaiting approval`} + > + + {pending} pending + + + ) : null} + + ); + }, }, { id: "contact", @@ -216,6 +240,20 @@ export default function CustomersPage() { style={{ flex: 1, minWidth: "240px" }} radius="lg" /> + { + setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "Pending approval", value: "pending" }, + { label: "Active", value: "active" }, + ]} + /> {total} record{total !== 1 ? "s" : ""} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index fe93865c7..ae795a892 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -1,4 +1,5 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; +import { useDisclosure } from "@mantine/hooks"; import { CalendarCheck, Clock, @@ -8,9 +9,7 @@ import { MapPin, Receipt, Settings, - Sparkles, } from "lucide-react"; -import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; import { Navigate, @@ -21,8 +20,11 @@ import { useNavigate, } from "react-router-dom"; -import useAuth from "./hooks/useAuth"; +import OnboardingResumeBanner, { + AccountReviewBanner, +} from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; +import useAuth from "./hooks/useAuth"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; @@ -37,11 +39,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import MyBookings from "./pages/bookings/MyBookings"; import NewBookingPage from "./pages/bookings/NewBookingPage"; -import ContractsList from "./pages/contracts/ContractsList"; import ContractDetailPage from "./pages/contracts/ContractDetailPage"; +import ContractsList from "./pages/contracts/ContractsList"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; -import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; +import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import TrackingPage from "./pages/tracking/TrackingPage"; function FullScreenSpinner() { @@ -146,10 +148,10 @@ function OnboardingGate() { return ( <> - {needsOnboarding && !wizardOpen && ( + {needsOnboarding && ( )} - {awaitingApproval && } + {!needsOnboarding && } void }) { - return ( -
-
- - - Finish setting up your company to unlock bookings, tracking and - billing. - -
- -
- ); -} - -/** Shown after onboarding while the company awaits backoffice approval. */ -function PendingApprovalBanner() { - return ( -
- - - Your company is awaiting EDR approval. You can browse, but creating - bookings is disabled until your company is approved. - -
- ); -} - /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); diff --git a/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx b/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx new file mode 100644 index 000000000..cb0e2e726 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx @@ -0,0 +1,60 @@ +import { Box, Button, Tooltip } from "@mantine/core"; +import { Link } from "react-router-dom"; +import { Lock, Plus } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; + +interface NewBookingButtonProps { + label?: string; + size?: string; + mt?: string; +} + +/** + * New-booking entry point that respects approval status: a customer can only + * create bookings under a profile once the backoffice has approved it. While the + * active profile is pending the button is disabled with an explanation, so the + * gate is communicated rather than silently failing at submit time. + */ +export function NewBookingButton({ + label = "New booking", + size, + mt, +}: NewBookingButtonProps) { + const { canBook, activeProfileStatus } = useAuth(); + + if (!canBook) { + const message = + activeProfileStatus === "pending" + ? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved." + : "Bookings aren't available for this profile yet."; + return ( + + + + + + ); + } + + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx new file mode 100644 index 000000000..d0024c6f6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -0,0 +1,191 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, Clock } from "lucide-react"; +import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; +import type { OnboardingRequirements } from "@/services/companies.service"; + +interface OnboardingResumeBannerProps { + /** Re-opens the onboarding wizard. */ + onResume: () => void; +} + +interface BannerCopy { + title: string; + subtitle: string; + cta: string; +} + +/** + * Wording is driven entirely by the backend's outstanding-items list — the + * client never decides what's required, it just narrates what's left. + */ +function getCopy( + requirements: OnboardingRequirements | undefined, + pct: number, +): BannerCopy { + // No data yet (or nothing started) — treat it as a fresh start. + if (!requirements || requirements.progress.completed === 0) { + return { + title: "Set up your company profile", + subtitle: "Unlock bookings, tracking and billing — it only takes a minute.", + cta: "Start onboarding", + }; + } + + // Everything's filled in but not yet submitted for review. + if (requirements.isComplete) { + return { + title: "Everything's ready to go", + subtitle: "Submit your profile to send it for approval.", + cta: "Submit for review", + }; + } + + const remaining = requirements.outstanding.length; + if (remaining <= 2) { + return { + title: `Almost done — you're ${pct}% set up`, + subtitle: `Just ${remaining} more ${ + remaining === 1 ? "item" : "items" + } to finish: ${requirements.outstanding.join(", ")}.`, + cta: "Finish onboarding", + }; + } + + return { + title: `You're ${pct}% set up`, + subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`, + cta: "Continue onboarding", + }; +} + +/** Circular percentage meter that reads at a glance against the dark banner. */ +function ProgressRing({ pct }: { pct: number }) { + const size = 56; + const stroke = 5; + const r = (size - stroke) / 2; + const circumference = 2 * Math.PI * r; + const offset = circumference * (1 - pct / 100); + + return ( + + + + + + {pct}% + + ); +} + +/** + * Prominent banner shown on onboarding-allowed pages after the wizard is + * dismissed. Progress and copy are read straight from the backend's onboarding + * requirements, so the banner always agrees with the wizard about what's left. + */ +export default function OnboardingResumeBanner({ + onResume, +}: OnboardingResumeBannerProps) { + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ retry: false }), + ); + + const requirements = requirementsQuery.data; + const { completed, total } = requirements?.progress ?? { + completed: 0, + total: 0, + }; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + const { title, subtitle, cta } = getCopy(requirements, pct); + + return ( +
+
+
+ + + + + + + + + {title} + + + {subtitle} + +
+ +
+
+ ); +} + +/** + * Shown once onboarding is submitted but the company's operational profiles are + * still being reviewed. Communicates that approval is per-profile and that + * bookings unlock as each profile is cleared. Self-hides when nothing is pending. + */ +export function AccountReviewBanner() { + const { company } = useAuth(); + const profiles = company?.company?.companyProfiles ?? []; + const pending = profiles.filter((p) => p.status === "pending"); + const approved = profiles.filter((p) => p.status === "active"); + + if (profiles.length === 0 || pending.length === 0) return null; + + const pendingLabel = pending + .map((p) => p.type.replace(/_/g, " ")) + .join(", "); + + return ( +
+
+
+ + + + + + Your account is under review + + + We're reviewing your {pendingLabel}{" "} + {pending.length === 1 ? "profile" : "profiles"}. You can create + bookings under a profile as soon as it's approved. + + +
+ + {approved.length} of {profiles.length} approved + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 3628d7403..8d6bc3d93 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -14,8 +14,11 @@ import { ArrowRight, Building2, CheckCircle2, + Clock, FileText, Globe2, + PartyPopper, + ShieldCheck, UploadCloud, User, UserCheck, @@ -43,6 +46,7 @@ type FormStep = | "company" | "personnel" | "contact" + | "verify" | "poa" | "documents" | "additional"; @@ -50,6 +54,7 @@ const FORM_STEPS: FormStep[] = [ "company", "personnel", "contact", + "verify", "poa", "documents", "additional", @@ -90,6 +95,11 @@ const STEP_META: Record< title: "Contact Person", description: "Who should we reach out to about this account?", }, + verify: { + icon: , + title: "Verify Contact Person", + description: "Confirm the contact phone with a one-time SMS code.", + }, poa: { icon: , title: "Power of Attorney", @@ -142,10 +152,14 @@ export default function OnboardingWizardDialog({ onClose, }: OnboardingWizardDialogProps) { const queryClient = useQueryClient(); - const { user, company, onboardingStep } = useAuth(); + const { user, company, onboardingStep, onboardingCompleted } = useAuth(); const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); + // A draft can exist with zero operational profiles (e.g. an interrupted start). + // Such a draft must re-run role selection so the profiles actually get created + // — otherwise the user is stuck with nothing to upload a license against. + const hasOperationalProfiles = existingProfiles.length > 0; const savedNationality = (company?.company?.nationality as CompanyNationality | null) ?? null; @@ -157,7 +171,11 @@ export default function OnboardingWizardDialog({ // Phases: nationality → role → form. If a draft already exists, resume // straight into the form with nationality + roles pre-selected. const [phase, setPhase] = useState<"nationality" | "role" | "form">( - companyAlreadyStarted ? "form" : "nationality", + companyAlreadyStarted + ? hasOperationalProfiles + ? "form" + : "role" + : "nationality", ); const [nationality, setNationality] = useState( savedNationality, @@ -174,6 +192,10 @@ export default function OnboardingWizardDialog({ // Mirror of CompanyProfileForm's active step so the global header + progress // pill can reflect it (the form no longer renders its own stepper). const [formStep, setFormStep] = useState(resumeFormStep); + // Once submission succeeds we swap the whole wizard body for a congratulations + // panel, and keep the modal open (the gate would otherwise tear it down the + // moment onboardingCompleted flips true). + const [completed, setCompleted] = useState(false); // Saved profile data, for rehydrating the form fields after a refresh. const profileQuery = useQuery( @@ -184,6 +206,19 @@ export default function OnboardingWizardDialog({ }), ); + // Server-driven onboarding requirements: the backend decides which document + // set applies (by nationality) and what's still outstanding, so the client + // never makes that choice itself. This is the heavier "second request" — it's + // only issued while onboarding is still incomplete; once the getInfo flag says + // we're done, it never fires. + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ + enabled: companyAlreadyStarted && !onboardingCompleted, + retry: false, + refetchOnWindowFocus: false, + }), + ); + const refreshInfo = useCallback( () => queryClient.invalidateQueries({ @@ -225,7 +260,10 @@ export default function OnboardingWizardDialog({ } return api.companies.completeOnboarding.call(); }, - onSuccess: refreshInfo, + onSuccess: async () => { + await refreshInfo(); + setCompleted(true); + }, onError: (err) => setStartError(extractApiError(err).message), }); @@ -260,7 +298,9 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); - setPhase("form"); + // Resume into the form only when profiles exist; otherwise send the user to + // role selection so the missing operational profiles get created. + setPhase(hasOperationalProfiles ? "form" : "role"); const idx = FORM_STEPS.indexOf(resumeFormStep); if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -329,8 +369,22 @@ export default function OnboardingWizardDialog({ const stepMeta = STEP_META[activeStep]; const activeIdx = WIZARD_STEPS.indexOf(activeStep); + // Closing from the congratulations panel also clears the completed flag so a + // future reopen (shouldn't happen once onboarded) starts clean. + const handleClose = useCallback(() => { + if (completed) setCompleted(false); + onClose(); + }, [completed, onClose]); + + // Prefer the backend-resolved document code; fall back to the local mapping + // only until the requirements query lands (the documents step is reached well + // after the draft — and thus the requirements — exist). + const resolvedDocumentSettingCode = + requirementsQuery.data?.documentSettingCode ?? + documentSettingCode(effectiveNationality); + const formProps = { - documentSettingCode: documentSettingCode(effectiveNationality), + documentSettingCode: resolvedDocumentSettingCode, documentFiles, onDocumentFilesChange: setDocumentFiles, user, @@ -346,16 +400,20 @@ export default function OnboardingWizardDialog({ roleProfiles, licenseFiles, onLicenseChange: setLicenseFiles, + // Surface a failed final submit (license/document upload or complete) inside + // the form — otherwise the server message (e.g. a 500) would be invisible on + // the submit step. + submitError: phase === "form" ? startError : null, }; return ( - - - {stepMeta.icon} - {stepMeta.title} - - - {stepMeta.description} - - - - + completed ? null : ( + + + + {stepMeta.icon} + {stepMeta.title} + + + {stepMeta.description} + + + + + ) } > + {completed ? ( + + ) : ( {phase === "nationality" ? ( @@ -438,10 +501,65 @@ export default function OnboardingWizardDialog({ )} + )} ); } +/** + * Replaces the wizard body once onboarding is submitted: congratulates the user + * and sets the expectation that their company is now under review, and that + * bookings unlock per profile as the team approves each one. + */ +function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { + return ( + + + + + + + You're all set! + + Thanks for completing your company profile. Your application has been + submitted and is now with our team for review. + + + + + + + + Each operational profile (importer, exporter, freight forwarder) is + reviewed and approved individually. + + + + + + You can start creating bookings under a profile as soon as it's + approved — we'll let you know the moment that happens. + + + + + + + ); +} + /** * Continuous progress pill: a single rounded track that fills left-to-right as * the user advances, with faint ticks marking each step boundary. diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6a22ef954..3d30b7d15 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -89,6 +89,7 @@ export const URL_CONSTANTS = { ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", + ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements", DASHBOARD: "/api/companies/dashboard", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index ec54a9a30..9d17b1fed 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -74,13 +74,6 @@ const useAuth = () => { setCookie("auth-token", res.token, 7); setCookie("refresh-token", res.refreshToken, 7); await authQuery.refetch(); - const otpCode = res.otp?.split(" ")?.[6] ?? ""; - localStorage.setItem("otp", otpCode); - localStorage.setItem("otp-phone", payload.phoneNumber); - localStorage.setItem("otp-email", payload.email); - api.auth.sendOTP - .call({ phone: payload.phoneNumber, otp: otpCode }) - .catch(() => { }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -164,6 +157,15 @@ const useAuth = () => { companyInfo?.profile?.onboardingCompleted ?? false; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + // Booking is gated on backoffice approval of the active operational profile: + // a customer can only book under a profile once its status is "active". + const activeProfile = + companyInfo?.company?.companyProfiles?.find( + (p) => p.id === activeCompanyProfileId, + ) ?? null; + const activeProfileStatus = activeProfile?.status ?? null; + const canBook = activeProfileStatus === "active"; + /** Refetch everything scoped to the active operational profile. */ const invalidateScopedData = async () => { await Promise.all([ @@ -232,6 +234,8 @@ const useAuth = () => { customer: isAuthenticated ? (companyQuery.data ?? null) : null, activeProfileType, activeCompanyProfileId, + activeProfileStatus, + canBook, companyType, companyStatus, isCompanyApproved, diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 4da182283..a8e37d44f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -9,7 +9,6 @@ import { HelloSection, InvoicesSection, RecentActivitySection, - SetupPrompt, ShipmentsSection, StatsSection, } from "./components"; @@ -67,8 +66,6 @@ export default function MyPortalPage() { )} - - !profile[field]); -} - -interface SetupPromptProps { - show: boolean; -} - -export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), - ); - - const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data); - - if (!show && !incomplete) return null; - - return ( - - - - - {incomplete && } - - {incomplete ? "Complete Your Profile" : "Setup your Company Profile"} - - - - {incomplete - ? "Your company profile is incomplete. Fill in the missing details to unlock all features." - : "Complete your company information to unlock all features and start booking shipments."} - - - - - {incomplete ? "Complete Profile" : "Complete Setup"} - - - - - - - - - - - ); -}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx index 413caafc4..a17af733d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx @@ -1,15 +1,30 @@ import { Box, Group, Text } from "@mantine/core"; -import { memo } from "react"; import type { LucideIcon } from "lucide-react"; +import { memo } from "react"; import { cv } from "../constants"; +/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */ +type Accent = "green" | "amber" | "blue" | "slate"; + +const ACCENTS: Record = { + green: { soft: cv("edr-soft"), ink: cv("edr-green.7") }, + amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") }, + blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") }, + slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") }, +}; + interface StatKpiProps { icon: LucideIcon; label: string; value: string; delta: string; - deltaColor: string; + /** Color family for the icon chip. */ + accent: Accent; + /** Tint of the delta pill — defaults to the card accent. */ + deltaTone?: Accent | "muted"; + /** Draw a separating border on the left (on wide layouts). */ divider?: boolean; + loading?: boolean; } export const StatKpi = memo(function StatKpi({ @@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({ label, value, delta, - deltaColor, + accent, + deltaTone, divider, + loading, }: StatKpiProps) { + const a = ACCENTS[accent]; + const tone = deltaTone ?? accent; + const pill = + tone === "muted" + ? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") } + : { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink }; + return ( - - - - {label} - - - - - {value} - - - {delta} - + {/* Icon chip + metric label, aligned on one line. */} + + + + + + + + {loading ? "—" : value} + + {delta && !loading && ( + + + {delta} + + + )} + + + {label} + + + + {/* Value + its trend pill, grouped together at the bottom of the cell. */} + ); }); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index 8a0545fb7..dbd12546b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,7 +1,7 @@ +import { formatCurrency } from "@/pages/billing/invoices.mock"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react"; import { memo } from "react"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; import { formatPct } from "../constants"; import { Card } from "./Card"; import { StatKpi } from "./StatKpi"; @@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({ completionRate, spendYtd, spendYtdChangePct, + dashboardLoading, }: StatsSectionProps) { return ( - - + + 0 ? `+${newActiveThisWeek} this week` : ""} + loading={bookingsLoading} /> diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts index de3ddb448..4bf94f45f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection"; export { HelloSection } from "./HelloSection"; export { InvoicesSection } from "./InvoicesSection"; export { RecentActivitySection } from "./RecentActivitySection"; -export { SetupPrompt } from "./SetupPrompt"; export { ShipmentsSection } from "./ShipmentsSection"; export { StatKpi } from "./StatKpi"; export { StatsSection } from "./StatsSection"; export { Stepper } from "./Stepper"; + diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index ab7cb0f40..e56c91714 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,10 +1,10 @@ import { Alert, Button, - Checkbox, Divider, Group, Loader, + PinInput, SimpleGrid, Stack, Text, @@ -16,7 +16,11 @@ import { AlertCircle, ArrowLeft, ArrowRight, - // UserCheck, + CheckCircle2, + RotateCw, + ShieldCheck, + Smartphone, + UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,15 +41,27 @@ import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; +import { extractApiError } from "@/utils/result"; type CompanyStep = | "company" | "personnel" | "contact" + | "verify" | "poa" | "documents" | "additional"; +/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ +const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); +const samePhone = (a?: string | null, b?: string | null) => { + const da = phoneDigits(a); + return da.length === 9 && da === phoneDigits(b); +}; +/** Mask all but the first 7 chars of an E.164 phone for display. */ +const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + const onboardingSchema = z.object({ companyFirstName: z.string().min(1, "First name is required"), companyLastName: z.string().min(1, "Last name is required"), @@ -73,11 +89,13 @@ const onboardingSchema = z.object({ renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - region: z.string().optional(), - zone: z.string().optional(), - woreda: z.string().optional(), - kebele: z.string().optional(), - houseNo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + woreda: z.string().min(1, "Woreda is required"), + kebele: z.string().min(1, "Kebele is required"), + houseNo: z.string().min(1, "House number is required"), etradePhone: z.string().optional(), contactPersonFirstName: z .string() @@ -95,12 +113,11 @@ const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - generalManagerFirstName: z.string().min(1, "GM first name is required"), - generalManagerLastName: z.string().min(1, "GM last name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), generalManagerPhone: z .string() - .min(1, "GM phone is required") + .min(1, "Manager phone is required") .refine(isValidPhone, "Enter a valid phone number"), poaFirstName: z.string().optional(), poaLastName: z.string().optional(), @@ -152,6 +169,7 @@ const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], + verify: [], poa: [], documents: [], additional: [], @@ -205,7 +223,10 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { } /** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload(step: CompanyStep, d: FormData): Partial { +function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { switch (step) { case "company": return { @@ -310,6 +331,20 @@ function toFormValues(p: ProfileResponse): FormData { }; } +/** A single read-only registration value rendered as a label/value pair. */ +function ReadOnlyField({ label, value }: { label: string; value?: string }) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -327,6 +362,7 @@ export default function CompanyProfileForm({ roleProfiles, licenseFiles, onLicenseChange, + submitError, }: { documentSettingCode: string; documentFiles?: Record; @@ -354,6 +390,8 @@ export default function CompanyProfileForm({ /** Newly-selected license files per profile id. */ licenseFiles?: Record; onLicenseChange?: (value: Record) => void; + /** Server error from the final submit (uploads/complete), shown verbatim. */ + submitError?: string | null; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -441,6 +479,36 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // eTrade carries no email, so the company/contact email fields start blank. + // Seed them from the registering user's account email — but only while empty, + // so a typed or rehydrated value is never overwritten. + useEffect(() => { + if (!user?.email) return; + if (!watch("companyEmail")) { + setValue("companyEmail", user.email, { shouldValidate: true }); + } + if (!watch("contactPersonEmail")) { + setValue("contactPersonEmail", user.email); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.email, rehydrate]); + + // Keep the (hidden, derived) company address in sync with the editable address + // fields — so it reflects both the eTrade auto-fill and any later user edits, + // instead of only whatever was composed at lookup time. + const region = watch("region"); + const zone = watch("zone"); + const woreda = watch("woreda"); + const kebele = watch("kebele"); + const houseNo = watch("houseNo"); + useEffect(() => { + const composed = [houseNo, kebele, woreda, zone, region] + .filter((part) => part && part.trim()) + .join(", "); + setValue("companyAddress", composed); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [region, zone, woreda, kebele, houseNo]); + // The business owner/manager pulled from eTrade — powers "Use owner as // manager" on the General Manager step. const [etradeOwner, setEtradeOwner] = useState<{ @@ -449,11 +517,6 @@ export default function CompanyProfileForm({ email?: string; } | null>(null); - // Mirror the three "copy from previous person" checkboxes. - const [ownerIsGm, setOwnerIsGm] = useState(false); - const [gmIsContact, setGmIsContact] = useState(false); - const [contactIsPoa, setContactIsPoa] = useState(false); - const handleETradeDataLoaded = (data: CompanyRegistrationData) => { // Company name comes from the eTrade manager/owner name on the license. if (data.managerName) { @@ -476,18 +539,9 @@ export default function CompanyProfileForm({ "etradePhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); - - // Compose a readable company address from the granular eTrade parts. - const addressParts = [ - data.houseNo, - data.kebele, - data.woreda, - data.zone, - data.region, - ].filter((part) => part && part.trim()); - if (addressParts.length) { - setValue("companyAddress", addressParts.join(", ")); - } + // companyAddress is composed reactively from the address fields below, so + // setting region/zone/woreda/kebele/houseNo above is enough — no need to + // compose it here. // Pre-fill the company contact phone from eTrade's mobile number. const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); @@ -519,34 +573,137 @@ export default function CompanyProfileForm({ }); }; - /** Copy the General Manager into the Contact Person fields (toggleable). */ - const toggleGmAsContact = (checked: boolean) => { - setGmIsContact(checked); - if (!checked) return; - setValue("contactPersonFirstName", watch("generalManagerFirstName")); - setValue("contactPersonLastName", watch("generalManagerLastName")); + /** Copy the General Manager into the Contact Person fields (still editable). */ + const useGmAsContact = () => { + setValue("contactPersonName", watch("generalManagerName"), { + shouldValidate: true, + }); setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone")); + setValue("contactPersonPhone", watch("generalManagerPhone"), { + shouldValidate: true, + }); }; - /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ - const toggleContactAsPoa = (checked: boolean) => { - setContactIsPoa(checked); - if (!checked) return; - setValue("poaFirstName", watch("contactPersonFirstName")); - setValue("poaLastName", watch("contactPersonLastName")); + /** Copy the Contact Person into the PoA fields (still editable). */ + const useContactAsPoa = () => { + setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); }; + /** Populate the Contact Person from the currently logged-in user. */ + const useLoggedInUserAsContact = () => { + setValue("contactPersonName", user?.name?.en ?? "", { + shouldValidate: true, + }); + if (user?.email) setValue("contactPersonEmail", user.email); + setValue("contactPersonPhone", user?.phoneNumber ?? "", { + shouldValidate: true, + }); + }; + + // --- Contact-phone SMS OTP verification ----------------------------------- + // The phone we verify is the contact-person phone, normalised to E.164 so it + // matches what the backend persists as `contactVerifiedPhone`. + const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? ""); + // Source of truth for "already verified" comes from the onboarding/profile + // info (rehydrate) — so a refresh resumes the verify step's "done" state. + const [verifiedPhone, setVerifiedPhone] = useState( + rehydrate?.contactVerifiedPhone ?? null, + ); + useEffect(() => { + if (rehydrate?.contactVerifiedPhone) { + setVerifiedPhone(rehydrate.contactVerifiedPhone); + } + }, [rehydrate?.contactVerifiedPhone]); + const phoneVerified = samePhone(verifiedPhone, contactPhoneE164); + + const [otpSent, setOtpSent] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [sendingOtp, setSendingOtp] = useState(false); + const [verifyingOtp, setVerifyingOtp] = useState(false); + const [otpError, setOtpError] = useState(null); + const [resendIn, setResendIn] = useState(0); + + // Resend cooldown countdown (no Date.now needed — pure setTimeout ticks). + useEffect(() => { + if (resendIn <= 0) return; + const t = setTimeout(() => setResendIn((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [resendIn]); + + // A changed contact phone invalidates any in-flight code entry (the previous + // code was for a different number). Verified state is handled separately via + // the phone comparison, so this only resets the send/enter UI. + useEffect(() => { + setOtpSent(false); + setOtpCode(""); + setOtpError(null); + }, [contactPhoneE164]); + + const sendContactOtp = async () => { + setOtpError(null); + if (!contactPhoneE164) { + setOtpError("Enter a valid contact phone number first."); + return; + } + setSendingOtp(true); + try { + await api.auth.sendOTP.call({ phone: contactPhoneE164 }); + setOtpSent(true); + setOtpCode(""); + setResendIn(60); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setSendingOtp(false); + } + }; + + const verifyContactOtp = async () => { + setOtpError(null); + if (otpCode.length !== 6) { + setOtpError("Enter the 6-digit code we sent you."); + return; + } + setVerifyingOtp(true); + try { + await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode }); + setVerifiedPhone(contactPhoneE164); + setOtpSent(false); + // Persist the verified phone so the step resumes as "done" after a refresh + // (best-effort — the OTP itself already succeeded server-side). + onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setVerifyingOtp(false); + } + }; + const hasDocuments = Boolean(uploadSetting?.fields?.length); + // The registration/license details come straight from the eTrade lookup and + // are not user-editable — shown as a read-only confirmation once a TIN lookup + // (or rehydration) has filled them in. The address fields below are separate: + // user-entered and required. We watch the values so the display stays current. + const registration = watch([ + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewalDate", + "renewedFrom", + "renewedTo", + ]); + const hasRegistrationDetails = registration.some((v) => v && v.trim()); + // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ "company", "personnel", "contact", + "verify", "poa", "documents", "additional", @@ -589,6 +746,20 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // Contact-phone verification gates advancing past the verify step. The + // verified phone is already persisted (on verify success), so there's + // nothing extra to save here. + if (step === "verify") { + if (!phoneVerified) { + setSaveError( + "Please verify the contact person's phone number to continue.", + ); + return; + } + setSaveError(null); + setStep(stepOrder[currentIdx + 1]); + return; + } // The documents step has nothing to persist; field steps validate + save // before advancing. if (step !== "documents") { @@ -678,113 +849,96 @@ export default function CompanyProfileForm({ /> - <> + {hasRegistrationDetails && ( + <> - - Registration Details - - - Auto-filled from eTrade — these fields cannot be edited. - + + + Registration Details + + + from eTrade · read-only + + - - - - - - - - - - - - - - Address Information - - - - - - - - - - - - + )} + + + + Address Information + + + + + + + + + + + + + )} @@ -834,15 +988,33 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - Contact Person - - toggleGmAsContact(e.currentTarget.checked)} - /> + + + Contact Person + + + + {watch("generalManagerName") && ( + + )} + + First Name *} @@ -883,32 +1055,132 @@ export default function CompanyProfileForm({ )} + {step === "verify" && ( + + + + + Verify the contact person + + + + We'll text a one-time code to the contact person's phone to + confirm it's reachable. This is required before you continue. + + + {!contactPhoneE164 ? ( + } + > + Add a valid contact phone number on the previous step first. + + ) : phoneVerified ? ( + } + title="Phone verified" + > + {maskPhone(contactPhoneE164)} has been verified. + + ) : ( + + + + + {maskPhone(contactPhoneE164)} + + + + {!otpSent ? ( + + ) : ( + + + Enter the 6-digit code we sent to{" "} + {maskPhone(contactPhoneE164)}. + + + + + + + + )} + + {otpError && ( + } + > + {otpError} + + )} + + )} + + )} + {step === "poa" && ( <> - - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. - - toggleContactAsPoa(e.currentTarget.checked)} + + + Power of Attorney details are optional. Fill them in if you + have them, or skip to continue. + + {watch("contactPersonName") && ( + + )} + + - - - - {})} + onChange={onLicenseChange ?? (() => { })} /> )} @@ -983,6 +1255,17 @@ export default function CompanyProfileForm({ )} + {submitError && ( + } + title="Couldn't submit your application" + > + {submitError} + + )} + {showBack ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 89c32d26f..e4f0a96c7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { ActionIcon, @@ -25,7 +25,6 @@ import { LayoutList, MoreVertical, Package, - Plus, Search, Train, Wallet, @@ -651,15 +650,7 @@ export default function MyBookings() { Track every cargo booking — from draft to delivery. - + {/* ── Summary stat cards ──────────────────────────────────────── */} @@ -823,17 +814,7 @@ export default function MyBookings() { : "Create your first booking to get started."} {!query && ( - + )} ) : ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 22dd92dd7..f551b090b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -29,7 +29,7 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import { BookingFormInputValues, @@ -67,6 +67,12 @@ export default function NewBookingPage() { api.bookings.referenceData.queryOptions(), ); + // Booking is gated on profile approval: a customer whose active profile isn't + // approved yet is bounced back to the list, where the gate is explained. + if (!auth.isPending && auth.company && !auth.canBook) { + return ; + } + if (!auth.isPending && !auth.company) { return ( ( + "companies", + "onboardingRequirements", + companiesService.getOnboardingRequirements, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index a5905589b..d326811b8 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -82,6 +82,47 @@ export interface CompanyInfoResponse { company: CompanyResponse; } +/** A single onboarding document field, as resolved and described by the backend. */ +export interface OnboardingDocumentField { + fileKey: string; + fileLabel: string; + helpText: string | null; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; + uploaded: boolean; +} + +export interface OnboardingLicenseProfile { + profileId: string; + type: string; + reference: string; + uploaded: boolean; +} + +/** + * Server-driven onboarding requirements. The portal renders this verbatim: the + * backend decides which documents apply (by nationality) and what is still + * outstanding, so the client never hardcodes required fields or document sets. + */ +export interface OnboardingRequirements { + documentSettingCode: string; + nationality: string; + companyInfo: { + complete: boolean; + missingFields: { key: string; label: string }[]; + }; + documents: OnboardingDocumentField[]; + licenseProfiles: OnboardingLicenseProfile[]; + progress: { completed: number; total: number }; + isComplete: boolean; + onboardingCompleted: boolean; + outstanding: string[]; +} + export interface CompanyProfileInput { type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; businessLicense?: string; @@ -229,6 +270,14 @@ export const companiesService = { return unwrap(response.data); }, + /** Server-driven list of outstanding onboarding requirements + completeness. */ + getOnboardingRequirements: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index d95949bb9..e9a84dd93 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -34,7 +34,8 @@ export interface SignupResponse { export interface OtpPayload { phone: string; - otp: string; + /** Required on verify; omitted on send (the server generates the code). */ + otp?: string; } export interface OtpResponse { diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 951a1f129..3d3f2bad6 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -29,6 +29,8 @@ export interface ProfileResponse { contactPersonPosition: string | null; contactPersonEmail: string | null; contactPersonPhone: string | null; + /** Phone that passed SMS OTP verification (resumes the verify step's state). */ + contactVerifiedPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; generalManagerPhone: string | null; @@ -66,6 +68,7 @@ export interface UpdateProfilePayload { contactPersonPosition?: string; contactPersonEmail?: string; contactPersonPhone?: string; + contactVerifiedPhone?: string; generalManagerName?: string; generalManagerEmail?: string; generalManagerPhone?: string; diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 6694f1c30..64637ee1a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -38,7 +38,7 @@ "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz", "@types/bcrypt": "^6.0.0", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index a21a009ec..9c5a22b0f 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -496,7 +496,7 @@ async function seedPaymentMethods() { { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, { type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' }, - { type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' }, + { type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI' }, { type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' }, { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, ]; diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 378a2a361..139782475 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -1,19 +1,21 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; -// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard. import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; @ApiTags('Agents') @Controller('agents') -// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM -// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only. @UseGuards(IamJwtGuard) @ApiBearerAuth('IAM-auth') export class AgentsController { constructor(private service: AgentsService) {} + @Get('me') + @ApiOperation({ summary: 'Get agent profile for logged-in IAM user' }) + getMe(@Request() req: any) { + return this.service.getMe(req.user?.id ?? req.user?.sub); + } @Post('bookings') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 1cee0b4e4..4d4b593fb 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -133,4 +133,10 @@ export class AgentsService { take: 20 }); } + + async getMe(iamUserId: string) { + const agent = await this.prisma.agent.findUnique({ where: { iamUserId } }); + if (!agent) throw new NotFoundException('No agent profile found for this user'); + return agent; + } } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index ad4a7cd20..77b3ca1f8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,6 +1,5 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; -import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { Throttle } from '@nestjs/throttler'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; @@ -47,7 +46,7 @@ export class BookingsController { } @Get('by-device') - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Get bookings by device ID', description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.' @@ -76,8 +75,8 @@ export class BookingsController { } @Get() - @ApiOperation({ - summary: 'List all bookings with filters (Admin/Agent)', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @@ -102,7 +101,7 @@ export class BookingsController { } @Post('guest') - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', description: `Creates a booking without requiring login. Supports all four booking types. @@ -256,7 +255,8 @@ export class BookingsController { } @Get('saved-passengers') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get saved passenger profiles', description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)' }) @@ -417,8 +417,8 @@ export class BookingsController { } @Get(':id/usage') - @ApiOperation({ - summary: 'Check if booking is in use', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Returns list of modules/data that reference this booking' }) @ApiResponse({ status: 200, description: 'Usage information retrieved' }) @@ -428,7 +428,8 @@ export class BookingsController { } @Get(':bookingRef') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get booking details by reference (no auth required)', description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.' }) @@ -452,8 +453,8 @@ export class BookingsController { } @Delete(':id') - @ApiOperation({ - summary: 'Delete booking (admin only)', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Permanently deletes a booking record' }) @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) @@ -463,8 +464,8 @@ export class BookingsController { } @Patch(':id') - @ApiOperation({ - summary: 'Update booking details', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Updates booking information for admin/agent operations' }) @ApiResponse({ status: 200, description: 'Booking updated successfully' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 21532fa44..a81c6eeb0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1027,9 +1027,10 @@ export class BookingsService { ); } - async getByRef(bookingRef: string) { + async getByRef(bookingRefOrId: string) { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ - where: { bookingRef }, + where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 7cd119d42..be3f9ba05 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -19,6 +19,7 @@ export class PassengersController { ) {} @Get() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all passengers with filters (Admin/Agent)', description: 'Returns paginated list of passengers with search filters' @@ -86,6 +87,7 @@ export class PassengersController { } @Post('verify-fayda') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Verify Ethiopian national ID via Verifayda 2.0', description: `**Standalone endpoint for pre-verification of Ethiopian national IDs** @@ -155,6 +157,7 @@ Pre-verify national ID to auto-fill passenger registration form before submissio } @Post('register') + @SetMetadata('isPublic', true) @UseGuards(OptionalJwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ @@ -249,6 +252,7 @@ The API automatically detects: } @Post('save-details') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Bulk save passenger details from booking flow', description: `**Endpoint for saving multiple passengers in a single booking** @@ -347,6 +351,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Patch(':id') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Update passenger details', description: 'Updates passenger information for admin/agent operations' @@ -358,6 +363,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Delete(':id') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete passenger (admin only)', description: 'Permanently deletes a passenger record and associated data' @@ -369,6 +375,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Get(':id/usage') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Check if passenger is in use', description: 'Returns list of modules/data that reference this passenger' diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index a9f6c8f77..893bdb6ae 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -32,46 +32,20 @@ export class PassengersService { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - let iamUserIdFilter: string[] | null = null; + const where: any = {}; - if (search || verified !== undefined) { - const conditions: string[] = []; - const params: any[] = []; - let idx = 1; - - if (search) { - conditions.push(`( - u.email ILIKE $${idx} OR - u.phone_number ILIKE $${idx} OR - (u.name->>'en') ILIKE $${idx} OR - (u.name->>'am') ILIKE $${idx} - )`); - params.push(`%${search}%`); - idx++; - } - - if (verified !== undefined) { - if (verified) { - conditions.push(`u.metadata->>'faydaVerified' = 'true'`); - } else { - conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`); - } - } - - const rows = await this.dataSource.query<{ id: string }[]>( - `SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`, - params, - ); - iamUserIdFilter = rows.map(r => r.id); - - if (iamUserIdFilter.length === 0) { - return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; - } + if (search) { + where.user = { + OR: [ + { email: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search, mode: 'insensitive' } }, + { fullName: { contains: search, mode: 'insensitive' } }, + ], + }; } - const where: any = {}; - if (iamUserIdFilter) { - where.iamUserId = { in: iamUserIdFilter }; + if (verified !== undefined) { + where.user = { ...(where.user ?? {}), faydaVerified: verified }; } const [items, total] = await Promise.all([ @@ -81,8 +55,22 @@ export class PassengersService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { + user: true, loyalty: true, + wallet: true, _count: { select: { bookings: true } }, + bookings: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { + contactEmail: true, + contactPhone: true, + seats: { take: 1, orderBy: { id: 'asc' }, select: { + passengerName: true, dateOfBirth: true, passportNumber: true, + passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true, + }}, + }, + }, }, }), this.prisma.passenger.count({ where }), @@ -97,16 +85,68 @@ export class PassengersService { : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); + // Collect guest contact details for bulk SavedPassengerProfile lookup + const guestContacts = items + .filter(p => !(p as any).user && !p.iamUserId) + .map(p => (p as any).bookings?.[0]) + .filter(Boolean); + const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[]; + const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[]; + + const savedProfiles = (guestEmails.length || guestPhones.length) + ? await this.prisma.savedPassengerProfile.findMany({ + where: { OR: [ + ...(guestEmails.length ? [{ email: { in: guestEmails } }] : []), + ...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []), + ]}, + orderBy: { createdAt: 'desc' }, + }) + : []; + + // Index by email then phone for O(1) lookup + const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s])); + const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s])); + return { items: items.map(passenger => { + const localUser = (passenger as any).user ?? null; const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; - const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; + const faydaVerified = localUser?.faydaVerified === true + || iam?.metadata?.faydaVerified === true + || iam?.metadata?.faydaVerified === 'true'; + const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null; + const guestSeat = guestBooking?.seats?.[0] ?? null; + const savedProfile = guestBooking + ? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null) + : null; return { id: passenger.id, - fullName: iam?.name?.en ?? iam?.name?.am ?? null, - email: iam?.email ?? null, - phone: iam?.phone_number ?? null, + fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null, + email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null, + phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null, + gender: localUser?.gender ?? iam?.metadata?.gender ?? null, + dateOfBirth: localUser?.dateOfBirth + ? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth) + : (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth + ? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0] + : (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))), + nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null, + nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null, + faydaVerified, + faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null, + passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null, + passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null, + passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null, + idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null, verified: faydaVerified, + lastLoginAt: localUser?.lastLoginAt ?? null, + role: localUser?.role ?? null, + loyalty: passenger.loyalty + ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 } + : null, + wallet: (passenger as any).wallet + ? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' } + : null, loyaltyTier: passenger.loyalty?.tier || 'BRONZE', loyaltyPoints: passenger.loyalty?.pointsBalance || 0, totalBookings: passenger._count.bookings, @@ -384,7 +424,21 @@ export class PassengersService { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - await this.prisma.passenger.delete({ where: { id } }); + await this.prisma.$transaction([ + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }), + this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }), + this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }), + this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }), + this.prisma.notification.deleteMany({ where: { passengerId: id } }), + this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), + this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }), + this.prisma.journey.deleteMany({ where: { passengerId: id } }), + this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }), + this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), + this.prisma.booking.deleteMany({ where: { passengerId: id } }), + this.prisma.passenger.delete({ where: { id } }), + ]); + return { deleted: true, passengerId: id }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 0b8d6fe18..6a1df8cb1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -7,6 +7,7 @@ import { Post, Query, Res, + SetMetadata, UseGuards, } from "@nestjs/common"; import { @@ -17,7 +18,7 @@ import { ApiOkResponse, ApiProduces, } from "@nestjs/swagger"; -import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; + import { SkipThrottle, Throttle } from "@nestjs/throttler"; import { Response } from "express"; import { PaymentsService } from "./payments.service"; @@ -65,7 +66,7 @@ export class PaymentsController { } @Post("initiate") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Initiate payment with nationality-based payment methods", description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`, @@ -75,14 +76,14 @@ export class PaymentsController { } @Get("intents/:bookingId") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Get payment intent status for a booking" }) getIntent(@Param("bookingId") bookingId: string) { return this.service.getIntentByBookingId(bookingId); } @Get("waafi/return") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + @@ -123,7 +124,7 @@ export class PaymentsController { } @Get("methods") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "List payment systems supported by the platform", description: @@ -136,7 +137,7 @@ export class PaymentsController { } @Get("checkout") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Browser checkout redirect", description: diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index ddddd8464..b946e7537 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -7,6 +7,7 @@ import { Post, Patch, Query, + SetMetadata, UseGuards, } from "@nestjs/common"; import { @@ -17,7 +18,6 @@ import { ApiQuery, ApiResponse, } from "@nestjs/swagger"; -import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto } from "./seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; @@ -30,7 +30,7 @@ export class SeatsController { // ── Seat Map ────────────────────────────────────────────────────────────── @Get("seatmap/:scheduleId") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Get seat map filtered by coach type", description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`, @@ -104,6 +104,7 @@ This makes it clear which segment of the route each seat is held for, enabling s } @Post("hold") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Hold seats for 15 minutes before booking (Public - Guest booking supported)", diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts index 88fe21ffd..6f2bbb73d 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts @@ -1,23 +1,38 @@ -import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { SystemConfigService } from './system-config.service'; import { IamGuard } from '../../common/iam-adapter'; import { Roles } from '../../common/roles.decorator'; -@ApiTags('System Config') -@ApiBearerAuth('IAM-auth') -@UseGuards(IamGuard) -@Roles('ADMIN') -@Controller('system-config') +@ApiTags('Config') +@Controller('config') export class SystemConfigController { constructor(private service: SystemConfigService) {} + @Get('fayda-status') + @SetMetadata('isPublic', true) + @SkipThrottle() + @ApiOperation({ summary: 'Get Fayda verification enabled status (public)' }) + getFaydaStatus() { + const enabled = process.env.VERIFAYDA_ENABLED !== 'false'; + return { enabled }; + } + @Get() + @ApiBearerAuth('IAM-auth') + @UseGuards(IamGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Get all system config (admin)' }) getAll() { return this.service.getAll(); } @Patch() + @ApiBearerAuth('IAM-auth') + @UseGuards(IamGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Update system config (admin)' }) update(@Body() body: Record) { return this.service.updateMany(body); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index d4d68ad1f..029be67e2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,7 +9,8 @@ export class TicketsController { constructor(private service: TicketsService) {} @Post('generate/:bookingId') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' }) @@ -18,6 +19,7 @@ export class TicketsController { } @Patch('update-seats/:bookingId') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Update ticket seats before final confirmation', description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.' @@ -69,9 +71,8 @@ export class TicketsController { } @Get(':bookingRef') - @ApiOperation({ - summary: 'Get ticket with QR code and passenger details (public)', - }) + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get ticket with QR code and passenger details (public)' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 8e04d1a87..13e31c91a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -85,6 +85,7 @@ export class TicketsService { ticketNumber: t.barcodePayload, bookingRef: t.bookingRef, booking: { + id: t.booking.id, bookingRef: t.booking.bookingRef, status: t.booking.status, bookingType: t.booking.bookingType, diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index f1a4cd2e5..a3a7a4bcd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -91,7 +91,7 @@ export default function PassengersPage() { case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; case 'gender': return p.gender || ''; case 'nationality': return p.nationality || ''; - case 'verified': return p.nationalId ? 'Yes' : 'No'; + case 'verified': return p.faydaVerified ? 'Yes' : 'No'; default: return ''; } }); @@ -117,15 +117,15 @@ export default function PassengersPage() { ), }, - { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone }, - { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' }, { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, + { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, { key: 'verified', label: 'Status', render: (p: any) => ( - - {p.nationalId ? 'Verified' : 'Unverified'} + + {p.faydaVerified ? 'Verified' : 'Unverified'} ), }, @@ -192,7 +192,7 @@ export default function PassengersPage() { {selectedPassenger && (() => { const p = selectedPassenger; const isVerified = !!p.faydaVerified || !!p.nationalId; - const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier; + const tier = p.loyalty?.tier || p.loyaltyTier; const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE; return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index fca15313f..68ece3408 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -11,6 +11,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; export default function TicketsPage() { const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); @@ -25,15 +26,23 @@ export default function TicketsPage() { const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); + const { user } = useAuthStore(); + // Excess baggage state const [excessModalOpen, setExcessModalOpen] = useState(false); const [excessTicket, setExcessTicket] = useState(null); const [excessKg, setExcessKg] = useState(''); const [excessCollectCash, setExcessCollectCash] = useState(false); - const [excessAgentId, setExcessAgentId] = useState(''); const [excessError, setExcessError] = useState(null); const [excessResult, setExcessResult] = useState(null); + const { data: agentData } = useQuery({ + queryKey: ['agent-me'], + queryFn: () => apiClient.get('/agents/me'), + enabled: !!user, + retry: false, + }); + const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (

{label}

@@ -105,7 +114,6 @@ export default function TicketsPage() { setExcessTicket(ticket); setExcessKg(''); setExcessCollectCash(false); - setExcessAgentId(''); setExcessError(null); setExcessResult(null); setExcessModalOpen(true); @@ -114,9 +122,11 @@ export default function TicketsPage() { const handleExcessSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!excessTicket) return; + const agentId = agentData?.id; + if (!agentId) { setExcessError('No agent profile found for your account'); return; } await excessMutation.mutateAsync({ - bookingId: excessTicket.bookingId, - agentId: excessAgentId, + bookingId: excessTicket.booking?.id ?? excessTicket.bookingId, + agentId, excessWeightKg: parseInt(excessKg), collectCash: excessCollectCash, }); @@ -371,6 +381,13 @@ export default function TicketsPage() { ]; const actions = [ + { + label: 'Baggage', + onClick: openExcessModal, + variant: 'secondary' as const, + icon: Package, + show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), + }, { label: 'Board', onClick: handleBoard, @@ -411,13 +428,6 @@ export default function TicketsPage() { variant: 'danger' as const, icon: Trash2, }, - { - label: 'Excess Baggage', - onClick: openExcessModal, - variant: 'secondary' as const, - icon: Package, - show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), - }, ]; const stations = stationsData?.items || []; @@ -736,16 +746,16 @@ export default function TicketsPage() {
Booking: {excessTicket?.booking?.bookingRef}
-
- - setExcessAgentId(e.target.value)} - required - /> -
+ {agentData && ( +
+ Agent: {agentData.agentCode} +
+ )} + {!agentData && ( +
+ ⚠ No agent profile linked to your account. +
+ )}
apiClient.get>('/system-config'), - update: (data: Record) => apiClient.patch>('/system-config', data), + getAll: () => apiClient.get>('/config'), + update: (data: Record) => apiClient.patch>('/config', data), }; diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index f7cabc212..5644c9b56 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -4,7 +4,7 @@ export const dynamic = 'force-dynamic'; import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; @@ -29,10 +29,6 @@ export default function ConfirmationPage() { const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); - const confirmMutation = useMutation({ - mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }), - }); - const { data: _booking } = useQuery({ queryKey: ['booking', bookingId], queryFn: async (): Promise => { @@ -54,13 +50,20 @@ export default function ConfirmationPage() { useEffect(() => { if (bookingId && !confirmAttempted.current) { confirmAttempted.current = true; - confirmMutation.mutate(); - - apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { - console.error('Failed to generate ticket:', err); + + // Only generate ticket if booking is already CONFIRMED (e.g. wallet payment) + // For other payment methods, ticket is generated by the payment webhook after payment completes + apiClient.get(`/bookings/${bookingId}`).then((data: any) => { + if (data?.status === 'CONFIRMED') { + apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { + console.error('Failed to generate ticket:', err); + }); + } + }).catch((err) => { + console.error('Failed to fetch booking status:', err); }); } - }, [bookingId, confirmMutation]); + }, [bookingId]); const copyPNR = () => { if (pnr) { diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index d2ed43878..0a3e1d65b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -380,6 +380,7 @@ export default function PassengersPage() { const router = useRouter(); const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); const { user, isAuthenticated, updateUser } = useAuthStore(); + const isInitialized = useAuthStore((s) => s.isInitialized); const [faydaEnabled, setFaydaEnabled] = useState(true); const [verificationStatus, setVerificationStatus] = useState>({}); const [saving, setSaving] = useState(false); @@ -435,8 +436,8 @@ export default function PassengersPage() { useEffect(() => { const populateForm = async () => { + if (!isInitialized) return; if (!isAuthenticated || !user?.id || !searchCriteria) { - console.log('Missing required data for population'); setFormInitialized(true); return; } @@ -475,7 +476,7 @@ export default function PassengersPage() { }; populateForm(); - }, [isAuthenticated, user, searchCriteria, setValue]); + }, [isInitialized, isAuthenticated, user, searchCriteria, setValue]); const openFaydaVerification = async (index: number) => { if (typeof window === 'undefined') return; diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index def68279c..fc06005f0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -60,21 +60,14 @@ export default function PaymentPage() { const paymentMutation = useMutation({ mutationFn: async (data: any) => { - // For TELEBIRR and WAAFI, use the initiate endpoint - if (data.method === 'TELEBIRR' || data.method === 'WAAFI') { - const response = await apiClient.post('/payments/initiate', { + // For all payment methods, use the initiate endpoint + try { + return await apiClient.post("/payments/initiate", { bookingId: data.bookingId, method: data.method, paymentMethodId: data.paymentMethodId, - platform: 'web' + platform: 'web', }); - - return response; - } - - // For other payment methods, try the regular payment intent API - try { - return await apiClient.post("/payments/intent", data); } catch (error) { console.log("Payment API not available, using mock payment"); // Mock payment response diff --git a/apps/edr-passenger-web/portal/src/lib/api-client.ts b/apps/edr-passenger-web/portal/src/lib/api-client.ts index 1a29658fd..472c66e70 100644 --- a/apps/edr-passenger-web/portal/src/lib/api-client.ts +++ b/apps/edr-passenger-web/portal/src/lib/api-client.ts @@ -15,21 +15,21 @@ class ApiClient { this.client.interceptors.request.use((config) => { const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; - if (token) { + if (token && token !== 'null' && token !== 'undefined') { config.headers.Authorization = `Bearer ${token}`; } return config; }); + const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me']; + this.client.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - // Don't redirect if it's a login or register request (invalid credentials) - const isAuthEndpoint = error.config?.url?.includes('/auth/login') || - error.config?.url?.includes('/auth/register'); - - if (!isAuthEndpoint && typeof window !== 'undefined') { + const url: string = error.config?.url || ''; + const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p)); + if (!isPublic && typeof window !== 'undefined') { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_user'); window.location.href = '/login'; diff --git a/local-packages/tria-plc-iamapi-common-0.7.3.tgz b/local-packages/tria-plc-iamapi-common-0.7.4.tgz similarity index 99% rename from local-packages/tria-plc-iamapi-common-0.7.3.tgz rename to local-packages/tria-plc-iamapi-common-0.7.4.tgz index 70dae003d..bb86dd134 100644 Binary files a/local-packages/tria-plc-iamapi-common-0.7.3.tgz and b/local-packages/tria-plc-iamapi-common-0.7.4.tgz differ diff --git a/packages/ui-common/src/components/skeleton.tsx b/packages/ui-common/src/components/skeleton.tsx index fe08ad75e..dc986d964 100644 --- a/packages/ui-common/src/components/skeleton.tsx +++ b/packages/ui-common/src/components/skeleton.tsx @@ -1,13 +1,13 @@ -import { cn } from "../lib/utils" +import { cn } from "../lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
- ) + ); } -export { Skeleton } +export { Skeleton };