diff --git a/.pnpm-store/v11/.pnpm-needs-build-marker b/.pnpm-store/v11/.pnpm-needs-build-marker new file mode 100644 index 000000000..e69de29bb diff --git a/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json b/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json new file mode 100644 index 000000000..1d4a3e729 --- /dev/null +++ b/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json @@ -0,0 +1 @@ +{"dependencies":{"pnpm":"11.1.1"}} \ No newline at end of file diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 000000000..8fdf9e7d3 Binary files /dev/null and b/.pnpm-store/v11/index.db differ 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/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 792a268ed..737eb33ff 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -61,7 +61,6 @@ import { ContainersModule } from './modules/container-management/containers.modu import { CargoesModule } from './modules/cargoes/cargoes.module'; import { RoutesModule } from './modules/routes/routes.module'; import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { FacilitiesModule } from './modules/facilities/facilities.module'; import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; @@ -123,7 +122,6 @@ import { LastMileModule } from './modules/last-mile/last-mile.module'; ContainersModule, CargoesModule, RoutesModule, - FacilitiesModule, WarehousesModule, OverviewModule, VehiclesModule, diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts index e9e183b25..b8a6f8afb 100644 --- a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -7,13 +7,13 @@ export function deriveTradeDirection( originYard: YardLike, destinationYard: YardLike, ): ScheduleTradeDirection { - const originCountry = originYard.country?.trim(); - const destinationCountry = destinationYard.country?.trim(); + const originCountry = originYard.country?.trim().toLowerCase(); + const destinationCountry = destinationYard.country?.trim().toLowerCase(); - if (originCountry === 'Djibouti') { + if (originCountry === 'djibouti') { return 'IMPORT'; } - if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') { + if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') { return 'EXPORT'; } return 'DOMESTIC'; diff --git a/apps/edr-freight-api/src/migrations/1810000000002-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1810000000002-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..a2a1aae17 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-AddVehicleCodeAndPlates.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddVehicleCodeAndPlates1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const hasCode = await queryRunner.hasColumn('freight.vehicles', 'code'); + if (!hasCode) { + await queryRunner.addColumn( + 'freight.vehicles', + new TableColumn({ name: 'code', type: 'varchar', isNullable: true }), + ); + } + + const hasPower = await queryRunner.hasColumn('freight.vehicles', 'power_plate_no'); + if (!hasPower) { + await queryRunner.addColumn( + 'freight.vehicles', + new TableColumn({ name: 'power_plate_no', type: 'varchar', isNullable: true }), + ); + } + + const hasTrailer = await queryRunner.hasColumn('freight.vehicles', 'trailer_plate_no'); + if (!hasTrailer) { + await queryRunner.addColumn( + 'freight.vehicles', + new TableColumn({ name: 'trailer_plate_no', type: 'varchar', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('freight.vehicles', 'trailer_plate_no'); + await queryRunner.dropColumn('freight.vehicles', 'power_plate_no'); + await queryRunner.dropColumn('freight.vehicles', 'code'); + } +} 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/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts new file mode 100644 index 000000000..757c20720 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Contact email/phone for an external profile is sourced from IAM (the user's + * identity) and from the company record, so the duplicated `email`/`phone` + * columns on external_profiles are redundant and are dropped. Dropping `email` + * also removes its UNIQUE constraint. + */ +export class DropEmailPhoneFromExternalProfiles1820000000011 + implements MigrationInterface +{ + name = 'DropEmailPhoneFromExternalProfiles1820000000011'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS email;`, + ); + await queryRunner.query( + `ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS phone;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-added as nullable (the original email was UNIQUE NOT NULL) since the + // dropped values cannot be recovered to satisfy those constraints. + await queryRunner.query( + `ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS email varchar(150);`, + ); + await queryRunner.query( + `ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS phone varchar(20);`, + ); + } +} 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 92ed5d689..c47064014 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -392,6 +392,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..a838495d5 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) { @@ -77,10 +139,12 @@ export class CompaniesService { } } - const existingProfile = await this.profilesRepo.findByEmail(identity.email); + const existingProfile = await this.profilesRepo.findByUserId( + identity.userId, + ); if (existingProfile) { throw new ConflictException( - `Profile with email ${identity.email} already exists`, + `Profile for user ${identity.userId} already exists`, ); } @@ -114,8 +178,6 @@ export class CompaniesService { companyId: company.id, firstName: identity.firstName, lastName: identity.lastName, - email: identity.email, - phone: normalizeE164(identity.phone) ?? identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, activeProfileType, @@ -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( @@ -191,15 +251,6 @@ export class CompaniesService { return this.getCompanyInfoByUserId(identity.userId); } - // A profile may exist for the same email under a different IAM id — block - // duplicates as the final create does. - const byEmail = await this.profilesRepo.findByEmail(identity.email); - if (byEmail) { - throw new ConflictException( - `Profile with email ${identity.email} already exists`, - ); - } - const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); const activeProfileType = @@ -224,8 +275,6 @@ export class CompaniesService { companyId: company.id, firstName: identity.firstName, lastName: identity.lastName, - email: identity.email, - phone: normalizeE164(identity.phone) ?? identity.phone, isPrimaryContact: true, activeProfileType, onboardingStep: "company", @@ -251,12 +300,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 +575,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) @@ -576,10 +626,10 @@ export class CompaniesService { async createProfile(dto: CreateExternalProfileDto): Promise { await this.findCompanyById(dto.companyId); - const existing = await this.profilesRepo.findByEmail(dto.email); + const existing = await this.profilesRepo.findByUserId(dto.userId); if (existing) { throw new ConflictException( - `Profile with email ${dto.email} already exists`, + `Profile for user ${dto.userId} already exists`, ); } @@ -622,12 +672,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 +720,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 +884,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 +986,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 +1015,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/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts index 7a9b94c44..ff0f94495 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -1,5 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; -import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator'; export class CreateExternalProfileDto { @IsUUID() @@ -20,16 +19,6 @@ export class CreateExternalProfileDto { @MaxLength(100) lastName!: string; - @IsEmail() - @IsNotEmpty() - email!: string; - - @IsOptional() - @IsString() - @MaxLength(20) - @IsValidPhone() - phone?: string; - @IsOptional() @IsString() @MaxLength(50) 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/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 7e17bcc60..256641074 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -10,8 +10,6 @@ export class ResponseExternalProfileDto { companyId: string; firstName: string; lastName: string; - email: string; - phone?: string | null; nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; @@ -34,8 +32,6 @@ export class ResponseExternalProfileDto { this.companyId = profile.companyId; this.firstName = profile.firstName; this.lastName = profile.lastName; - this.email = profile.email; - this.phone = profile.phone; this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; 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/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 3b1554cc9..93e499b5e 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'last_name', type: 'varchar', length: 100 }) lastName!: string; - @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) - email!: string; - - @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) - phone?: string | null; - @Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true }) nationalId?: string | null; diff --git a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts index 581dfd72b..70c05abd7 100644 --- a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts @@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository { async findByCompanyId(companyId: string): Promise { return this.repository.find({ where: { companyId } as any }); } - - async findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } as any }); - } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 161604e81..78c3d43ff 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -59,7 +59,7 @@ export class FirstMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) acceptBooking(@Param('reference') reference: string) { - return this.firstMileService.acceptBooking(reference); + return this.firstMileService.acceptBookingByReference(reference); } @Post() diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 382499da8..bf6815af7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -2,13 +2,22 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { DriversModule } from '../drivers/drivers.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([FirstMile]), forwardRef(() => BookingsModule)], + imports: [ + TypeOrmModule.forFeature([FirstMile]), + forwardRef(() => BookingsModule), + VehiclesModule, + DriversModule, + NotificationsModule, + ], controllers: [FirstMileController], providers: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService], diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a4ead8c2a..a00e54fff 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,7 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { DriversService } from '../drivers/drivers.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; @@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [ @Injectable() export class FirstMileService { + private readonly logger = new Logger(FirstMileService.name); + constructor( private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, + private readonly vehiclesService: VehiclesService, + private readonly driversService: DriversService, + private readonly notificationsService: NotificationsService, ) {} /** @@ -36,8 +44,8 @@ export class FirstMileService { * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingReference: string): Promise { - const booking = await this.bookingsRepository.findByReference(bookingReference); + async acceptBooking(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { return null; @@ -53,6 +61,22 @@ export class FirstMileService { }); } + async acceptBookingByReference(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + return null; + } + + if (booking.paymentStatus !== 'PAID') { + return null; + } + + return this.create({ + bookingId: booking.id, + advancedPayment: 0, + }); + } async findAll(filter: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; meta: { total: number; page: number; pageSize: number; totalPages: number }; @@ -119,7 +143,7 @@ export class FirstMileService { } async update(id: string, dto: UpdateFirstMileDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -135,9 +159,45 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + // Notify assigned driver on every explicit vehicle assignment or reassignment + if (dto.vehicleId) { + void this.notifyDriverAssignment(dto.vehicleId, existing); + } + return updated; } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + if (!vehicle.assignedDriverId) { + this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); + return; + } + + const driver = await this.driversService.findById(vehicle.assignedDriverId); + if (!driver.phoneNumber) { + this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + return; + } + + const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; + + await this.notificationsService.notifyDriverVehicleAssignment({ + driverPhone: driver.phoneNumber, + driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), + vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, + bookingReference: booking?.reference ?? record.bookingId, + pickupAddress: booking?.firstMilePickupAddress, + destinationYard: booking?.originYard?.label, + }); + + this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + } catch (err) { + this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); + } + } + async remove(id: string): Promise { await this.findById(id); await this.firstMileRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index ea1e29a3d..e8abf52c6 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -59,7 +59,7 @@ export class LastMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) acceptBooking(@Param('reference') reference: string) { - return this.lastMileService.acceptBooking(reference); + return this.lastMileService.acceptBookingByReference(reference); } @Post() diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index fa654f6ec..32c2de721 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -2,13 +2,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { DriversModule } from '../drivers/drivers.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule], + imports: [ + TypeOrmModule.forFeature([LastMile]), + BookingsModule, + VehiclesModule, + DriversModule, + NotificationsModule, + ], controllers: [LastMileController], providers: [LastMileRepository, LastMileService], exports: [LastMileRepository, LastMileService], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index d25729324..b764d3428 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,7 +1,10 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { DriversService } from '../drivers/drivers.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ @Injectable() export class LastMileService { + private readonly logger = new Logger(LastMileService.name); + constructor( private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, + private readonly vehiclesService: VehiclesService, + private readonly driversService: DriversService, + private readonly notificationsService: NotificationsService, ) {} async acceptBooking(bookingReference: string): Promise { @@ -46,7 +54,26 @@ export class LastMileService { return this.create({ bookingId: booking.id, - advancedPayment: booking.totalAmount, + advancedPayment: 0, + }); + } + + async acceptBookingByReference(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + throw new NotFoundException(`Booking ${bookingReference} not found`); + } + + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`, + ); + } + + return this.create({ + bookingId: booking.id, + advancedPayment: 0, }); } @@ -116,7 +143,7 @@ export class LastMileService { } async update(id: string, dto: UpdateLastMileDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -132,9 +159,50 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${id} not found`); } + // Notify assigned driver on every explicit vehicle assignment or reassignment + if (dto.vehicleId) { + void this.notifyDriverAssignment(dto.vehicleId, existing); + } + return updated; } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + if (!vehicle.assignedDriverId) { + this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); + return; + } + + const driver = await this.driversService.findById(vehicle.assignedDriverId); + if (!driver.phoneNumber) { + this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + return; + } + + type BookingWithYards = { + reference?: string; + lastMileDeliveryAddress?: string | null; + destinationYard?: { label?: string } | null; + }; + const booking = (record as LastMile & { booking?: BookingWithYards }).booking; + + await this.notificationsService.notifyDriverVehicleAssignment({ + driverPhone: driver.phoneNumber, + driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), + vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, + bookingReference: booking?.reference ?? record.bookingId, + pickupAddress: booking?.destinationYard?.label, + destinationYard: booking?.lastMileDeliveryAddress, + }); + + this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + } catch (err) { + this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); + } + } + async remove(id: string): Promise { await this.findById(id); await this.lastMileRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 2ff2f9727..70e00c9ac 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -1,14 +1,14 @@ import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; import { NotificationsService } from "./notifications.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; -import { HttpModule } from "@nestjs/axios"; @Module({ - imports: [HttpModule], + imports: [ConfigModule], controllers: [], providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService], exports: [NotificationsService], }) -export class NotificationsModule { } +export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts index 35e8ff07d..088f2bbe0 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts @@ -27,9 +27,29 @@ export class NotificationsService { if (!strategy) { throw new NotFoundException(); } - const sent = await strategy.send(recipient, message) - this.logger.log(`is sent - ${sent}`) + const sent = await strategy.send(recipient, message); + this.logger.log(`is sent - ${sent}`); } + async notifyDriverVehicleAssignment(params: { + driverPhone: string; + driverName: string; + vehiclePlateNumber: string; + bookingReference: string; + pickupAddress?: string | null; + destinationYard?: string | null; + }): Promise { + const { driverPhone, driverName, vehiclePlateNumber, bookingReference, pickupAddress, destinationYard } = params; + const message = + `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + + `Booking: ${bookingReference}. Vehicle: ${vehiclePlateNumber}. ` + + (pickupAddress ? `Pickup: ${pickupAddress}. ` : '') + + (destinationYard ? `Destination: ${destinationYard}.` : ''); + try { + await this.directSend('sms', driverPhone, message); + } catch (err) { + this.logger.error(`Failed to notify driver ${driverName} (${driverPhone}): ${String(err)}`); + } + } } diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index 2f8916845..127eae5cd 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -1,25 +1,36 @@ -import { Injectable} from "@nestjs/common"; -import { NotificationStrategy } from "./notification.strategy"; -import { HttpService } from '@nestjs/axios'; +import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { firstValueFrom } from 'rxjs'; +import axios from "axios"; + +import { NotificationStrategy } from "./notification.strategy"; @Injectable() export class SmsNotificationStrategy implements NotificationStrategy { - constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { } - async send(recipient: string, message: string) { - const url = this.configService.get("OZIKING_SMS_URL") - const body = { - to: recipient, - text: message - } - const response = await firstValueFrom( - this.httpService.post( - url, - body, - ), - ); + constructor(private readonly configService: ConfigService) {} - return response.status === 201; - } + async send(recipient: string, message: string): Promise { + const url = + this.configService.get("OZIKING_SMS_URL") ?? + "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms"; + + await axios.post( + url, + { + to: recipient, + sourceId: this.configService.get("OZIKING_SOURCE_ID") ?? "EDR", + sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight", + appKey: this.configService.get("OZIKING_APP_KEY") ?? "", + text: message, + callbackUrl: "", + }, + { + headers: { + accept: "*/*", + "Content-Type": "application/json", + }, + }, + ); + + return true; + } } 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-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index d0eb2eecb..8a4d946ad 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -64,4 +64,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; controllers: [PaymentController, InternalPaymentController], exports: [PaymentService], }) -export class PaymentModule { } +export class PaymentModule { } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 0c01dd219..08fc74172 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -436,7 +436,7 @@ export class TrainSchedulingController { return this.trainSchedulingService.cancelTrainSchedule(id); } - @Post("bulk/schedules/:id/cancel") + @Post('bulk/schedules/:id/cancel') @TrainSchedulingManage() @ApiOperation({ summary: "Cancel bulk train schedule" }) cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index accc18be9..92aca8ac8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,4 +1,4 @@ -import { +import { AllocationLoadType, SchedulingStatus, TrainCheckpointKind, @@ -1005,12 +1005,29 @@ export class TrainSchedulingService { }); } - const arrivingLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); - if (arrivingLocoIds.length) { - await manager.getRepository(Locomotive).update( - { id: In(arrivingLocoIds) }, - { status: 'AVAILABLE', currentYardId: schedule.destinationStationId }, - ); + await manager.query( + `UPDATE freight.bookings b + SET status = $2, + scheduling_status = $3 + FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = b.id + AND tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`, + [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], + ); + + if (schedule.trainSet?.locomotiveId) { + const loco = await manager + .getRepository(Locomotive) + .findOne({ where: { id: schedule.trainSet.locomotiveId } }); + if (loco) { + await manager.getRepository(Locomotive).update(loco.id, { + status: 'AVAILABLE', + currentYardId: schedule.destinationStationId, + }); + } } for (const slot of schedule.trainSet?.wagons ?? []) { diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 8a2ad8519..55e12047b 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -37,4 +37,16 @@ export class CreateVehicleDto { @IsOptional() @IsString() assignedDriverName?: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + powerPlateNo?: string; + + @IsOptional() + @IsString() + trailerPlateNo?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 005118b0a..713edfc64 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -62,4 +62,13 @@ export class Vehicle extends BaseEntity { @Column({ name: 'assigned_driver_name', nullable: true }) assignedDriverName?: string; + + @Column({ name: 'code', nullable: true }) + code?: string; + + @Column({ name: 'power_plate_no', nullable: true }) + powerPlateNo?: string; + + @Column({ name: 'trailer_plate_no', nullable: true }) + trailerPlateNo?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts index 7851f9485..1080480fa 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository { }; } - async createVehicle(vehicleData: any): Promise { + async createVehicle(vehicleData: Partial): Promise { const vehicle = this.repository.create(vehicleData); - const vehicles = await this.repository.save(vehicle); - return vehicles?.[0] as Vehicle; + return this.repository.save(vehicle); } async updateVehicle(vehicle: Vehicle): Promise { - return (await this.repository.save(vehicle)) as Vehicle; + return this.repository.save(vehicle); } } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 42db52231..195b4932b 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; export const WAGON_STATUSES = [ WagonStatus.Available, WagonStatus.Assigned, + WagonStatus.ImportReady, + WagonStatus.ExportReady, WagonStatus.Maintenance, WagonStatus.Retired, ] as const; @@ -65,7 +67,7 @@ export class Wagon extends BaseEntity { @JoinColumn({ name: 'current_train_schedule_id' }) currentTrainSchedule?: TrainSchedule | null; - /** Fleet master consist grouping — separate from operational train_schedules. */ + /** Fleet master consist grouping — separate from operational train_schedules. */ @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'train_id' }) train!: Train | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index c867eec3c..9f89e7c2c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto { @IsUUID() warehouseId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() @@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto { @IsOptional() @IsString() search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + dateFrom?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + dateTo?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts index cba259d00..4de117064 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts @@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto { @ApiPropertyOptional() @IsOptional() @IsString() + bookingReference?: string; + + @ApiPropertyOptional({ description: 'Legacy alias for bookingReference' }) + @IsOptional() + @IsString() bookingNumber?: string; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts index f110dfcf7..1cfbc4661 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts @@ -78,17 +78,13 @@ export class WarehouseAllocationService { /** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */ async resolveLocation(criteria: AllocationCriteria): Promise { const rule = await this.findMatchingRule(criteria); - const yardCode = rule?.targetYardCode; + if (!rule) return null; - // Resolve yard (by rule code, else first available yard with a zone). + // Resolve yard by rule code. const [yard] = await this.dataSource.query( - yardCode - ? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y - WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1` - : `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y - JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL - WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`, - yardCode ? [yardCode] : [], + `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y + WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`, + [rule.targetYardCode], ); if (!yard) return null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts index fcc09f668..0bbcdee48 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { DataSource, IsNull } from 'typeorm'; +import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from 'typeorm'; import { Warehouse } from './entities/warehouse.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -26,6 +26,29 @@ export interface WarehouseDashboard { export class WarehouseDashboardService { constructor(private readonly dataSource: DataSource) {} + private async safeCount( + repo: Repository, + options?: FindManyOptions, + ): Promise { + try { + return await repo.count(options); + } catch { + return 0; + } + } + + private async safeReceivedToday(startOfToday: Date): Promise { + try { + return await this.dataSource + .getRepository(WarehouseInventory) + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(); + } catch { + return 0; + } + } + async getDashboard(): Promise { const warehouseRepo = this.dataSource.getRepository(Warehouse); const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); @@ -47,21 +70,18 @@ export class WarehouseDashboardService { delivered, receivedToday, ] = await Promise.all([ - warehouseRepo.count(), - inventoryRepo.count(), - inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), - inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }), - inventoryRepo.count({ where: { status: 'STORED' } }), - inventoryRepo.count({ where: { status: 'RESERVED' } }), - inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), - inventoryRepo.count({ where: { status: 'LOADED' } }), - inventoryRepo.count({ where: { status: 'DISPATCHED' } }), - inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }), - inventoryRepo.count({ where: { status: 'DELIVERED' } }), - inventoryRepo - .createQueryBuilder('inv') - .where('inv.arrived_at >= :start', { start: startOfToday }) - .getCount(), + this.safeCount(warehouseRepo), + this.safeCount(inventoryRepo), + this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), + this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }), + this.safeCount(inventoryRepo, { where: { status: 'STORED' } }), + this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }), + this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }), + this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }), + this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }), + this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }), + this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }), + this.safeReceivedToday(startOfToday), ]); return { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index ccf66deb4..61f991d23 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -13,6 +13,8 @@ interface ItemAttributes { tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; + inventoryQuantity: number; + bookingContainerCount: number; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -31,6 +33,8 @@ export interface FeePreview { endIsOpen: boolean; // true when still accruing (no release/gate-clear yet) elapsedDays: number; chargeableDays: number; + containerCount: number; + billableUnits: number; amount: number; } @@ -67,6 +71,7 @@ export class WarehouseFeeService { `SELECT inv.arrived_at AS "arrivedAt", inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", + inv.quantity AS "inventoryQuantity", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", @@ -74,7 +79,8 @@ export class WarehouseFeeService { b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode", - ctt.code AS "containerTypeCode" + ctt.code AS "containerTypeCode", + COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -82,6 +88,12 @@ export class WarehouseFeeService { LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count + FROM freight.booking_container bc + WHERE bc.booking_id = inv.booking_id + AND bc.deleted_at IS NULL + ) container_lines ON true WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); @@ -131,12 +143,18 @@ export class WarehouseFeeService { const endIsOpen = !item.gateClearedAt && !item.releaseDate; const freeDays = rule?.freeDays ?? 0; const ratePerDay = Number(rule?.ratePerDay ?? 0); + const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const containerCount = isContainer + ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) + : 1; const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) : 0; const chargeableDays = Math.max(0, elapsedDays - freeDays); - const amount = Math.round(chargeableDays * ratePerDay * 100) / 100; + const billableUnits = chargeableDays * containerCount; + const amount = Math.round(billableUnits * ratePerDay * 100) / 100; return { ruleType, @@ -150,6 +168,8 @@ export class WarehouseFeeService { endIsOpen, elapsedDays, chargeableDays, + containerCount, + billableUnits, amount, }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 9d1d0f148..3ee45e2e1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -18,7 +18,7 @@ export class WarehouseInspectionService { private readonly filesService: FilesService, ) {} - /** Create an inspection report for an inventory item and sync its inspectionStatus. */ + /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ async create(inventoryId: string, dto: CreateInspectionReportDto): Promise { const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } }); @@ -29,8 +29,9 @@ export class WarehouseInspectionService { const expected = dto.expectedWeight ?? null; const actual = dto.actualWeight ?? null; const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; + const inspectedAt = new Date(); - const report = await this.inspectionRepository.create({ + const payload = { inventoryId, bookingId: inventory.bookingId ?? null, reportType: dto.reportType, @@ -46,13 +47,27 @@ export class WarehouseInspectionService { missingItemsDescription: dto.missingItemsDescription ?? null, remarks: dto.remarks ?? null, inspectedById: dto.inspectedById ?? null, - inspectedAt: new Date(), + inspectedAt, + }; + + const [existingReport] = await this.inspectionRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + take: 1, }); + let report: WarehouseInspectionReport; + if (existingReport) { + await this.inspectionRepository.update(existingReport.id, payload); + report = await this.findById(existingReport.id); + } else { + report = await this.inspectionRepository.create(payload); + } + // Mirror the latest outcome onto the inventory item so loading rules can read it. await inventoryRepo.update(inventoryId, { inspectionStatus: dto.inspectionStatus, - inspectedAt: new Date(), + inspectedAt, }); return report; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 2699adbef..6e2f17b39 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -226,6 +227,16 @@ export class WarehouseInventoryController { return this.inventoryService.release(id, dto); } + @Get(':id/release-document') + @ApiOperation({ summary: 'View warehouse release / exit paper PDF' }) + async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.releaseDocument(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 31975b089..bc47e535c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; +import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -38,8 +39,11 @@ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'A export interface InventoryInquiryResult { id: string; + inventoryId: string | null; bookingId: string | null; + bookingReference: string | null; bookingNumber: string | null; + bookingStatus: string | null; customerName: string | null; containerNumber: string | null; cargoType: string | null; @@ -48,32 +52,32 @@ export interface InventoryInquiryResult { warehouse: { id: string; name: string; code: string } | null; yard: { id: string; name: string; code: string } | null; zone: { id: string; name: string; code: string } | null; - status: string; + status: string | null; + trainNumber: string | null; + trainStatus: string | null; + route: string | null; + locationSummary: string | null; quantity: number; weight: number; arrivedAt: Date | null; readyForLoadingAt: Date | null; } -interface LocationNode { - maxWeight?: number | null; - capacityWeight?: number | null; - maxVolume?: number | null; - capacityContainers?: number | null; - currentWeight: number; - currentVolume: number; - currentContainers: number; +interface BookingSummaryRow { + id: string; + reference: string | null; + status: string | null; + customer: string | null; } -// ── Batch 4.5 result/queue shapes ──────────────────────────────────────────── interface ArrivalQueueRow { bookingId: string; - bookingReference: string; + bookingReference: string | null; customer: string | null; cargo: string | null; container: string | null; arrivalDate: Date | null; - bookingStatus: string; + bookingStatus: string | null; inventoryId: string | null; currentStatus: string | null; inspectionStatus: string | null; @@ -85,7 +89,7 @@ interface ArrivalQueueRow { export interface ArrivalQueueItem { bookingId: string; - bookingReference: string; + bookingReference: string | null; customer: string | null; cargo: string | null; container: string | null; @@ -102,22 +106,71 @@ export interface ArrivalQueueItem { interface DefaultLocation { warehouseId: string; + facilityId?: string | null; yardId: string; zoneId: string; - facilityId: string | null; +} + +interface StorageAllocationLocation extends DefaultLocation { + path?: string | null; + rule?: { id: string; name: string; storageType: string | null } | null; +} + +interface InventoryAllocationCriteria { + freightType?: string | null; + tradeDirection?: string | null; + cargoTypeCode?: string | null; + containerStatus?: string | null; + requiresInspection?: boolean | null; } export interface AutoUnloadResult { processedCount: number; skippedCount: number; failedCount: number; - results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; + results: Array<{ + bookingId: string; + inventoryId?: string; + status: 'PROCESSED' | 'FAILED'; + reason?: string; + }>; } export interface AutoLoadResult { loadedCount: number; skippedCount: number; - results: { inventoryId: string; status: string; reason?: string }[]; + results: Array<{ + inventoryId: string; + status: 'LOADED' | 'SKIPPED'; + reason?: string; + }>; +} + +interface WarehouseDashboardSummary { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + +interface LocationRef { + warehouseId: string; + yardId: string; + zoneId: string; +} + +interface LocationNode { + capacityWeight?: number | null; + capacityContainers?: number | null; + currentWeight: number; + maxWeight?: number | null; + maxVolume?: number | null; + currentVolume?: number | null; + currentContainers: number; } // ── Receive (Import/Export bulk) shapes ────────────────────────────────────── @@ -210,6 +263,7 @@ export class WarehouseInventoryService { private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, + private readonly pdfService: ContractPdfService, ) {} /** @@ -242,7 +296,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - findAll(filter: FilterWarehouseInventoryDto): Promise { + async findAll(filter: FilterWarehouseInventoryDto): Promise { + const createdAt = + filter.dateFrom && filter.dateTo + ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) + : filter.dateFrom + ? MoreThanOrEqual(new Date(filter.dateFrom)) + : filter.dateTo + ? LessThanOrEqual(new Date(filter.dateTo)) + : undefined; + const base = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), @@ -252,6 +315,8 @@ export class WarehouseInventoryService { ...(filter.containerId ? { containerId: filter.containerId } : {}), ...(filter.goodsId ? { goodsId: filter.goodsId } : {}), ...(filter.status ? { status: filter.status } : {}), + ...(createdAt ? { createdAt } : {}), + ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), }; const search = filter.search?.trim(); @@ -259,11 +324,13 @@ export class WarehouseInventoryService { ? { ...base, notes: ILike(`%${search}%`) } : base; - return this.inventoryRepository.findAll({ + const items = await this.inventoryRepository.findAll({ where, - relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true }, + relations: { warehouse: true, yard: true, zone: true }, order: { createdAt: 'DESC' }, }); + await this.attachBookingSummaries(items); + return items; } findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { @@ -272,7 +339,7 @@ export class WarehouseInventoryService { async findById(id: string): Promise { const item = await this.inventoryRepository.findById(id, { - relations: { warehouse: true, yard: true, zone: true }, + relations: { warehouse: { facility: true }, yard: true, zone: true }, }); if (!item) { @@ -800,13 +867,8 @@ export class WarehouseInventoryService { return result; } - /** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */ - private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [ - 'IN_TRANSIT', - 'ARRIVED_AT_INDODE', - 'ARRIVED_AT_DESTINATION', - 'ARRIVED_AT_FACILITY', - ]; + /** Booking statuses that must never be unloaded into warehouse inventory. */ + private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; /** * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. @@ -875,8 +937,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason }); }; - if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) { - skip(`Booking status ${booking.status} is not unload-eligible`); + if (this.IMPORT_UNLOAD_BLOCKED_STATUSES.includes(booking.status)) { + skip(`Booking status ${booking.status} cannot be unloaded`); continue; } @@ -1063,7 +1125,7 @@ export class WarehouseInventoryService { }), ); - await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); await this.activityLog.record( { @@ -1082,15 +1144,148 @@ export class WarehouseInventoryService { return this.findById(id); } + async move(id: string, dto: MoveInventoryDto): Promise { + const movedId = await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + + if ( + item.warehouseId === dto.warehouseId && + item.yardId === dto.yardId && + item.zoneId === dto.zoneId + ) { + throw new BadRequestException('Destination location is the same as current location'); + } + + const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + const weight = Number(item.weight) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + if (item.warehouseId !== dto.warehouseId) { + this.assertCapacity('Warehouse', warehouse, weight, Number(item.volume) || 0, containerCount); + } + if (item.yardId !== dto.yardId) { + this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount); + } + this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + + await this.applyCapacityDelta( + manager, + { + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + }, + -weight, + -(Number(item.volume) || 0), + -containerCount, + ); + + await this.applyCapacityDelta(manager, dto, weight, Number(item.volume) || 0, containerCount); + + item.warehouseId = dto.warehouseId; + item.yardId = dto.yardId; + item.zoneId = dto.zoneId; + if (dto.remarks?.trim()) { + const existingNotes = item.notes?.trim(); + item.notes = existingNotes + ? `${existingNotes}\nMove: ${dto.remarks.trim()}` + : `Move: ${dto.remarks.trim()}`; + } + + const saved = await manager.getRepository(WarehouseInventory).save(item); + return saved.id; + }); + + return this.findById(movedId); + } + // ── Lifecycle transitions ──────────────────────────────────────────────── - store(id: string, performedBy?: string): Promise { - return this.transition(id, 'STORED', { - timestampField: 'storedAt', - activityType: 'INVENTORY_STORED', - description: 'Inventory stored', - performedBy, + async store(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'STORED'); + + const criteria = await this.getInventoryAllocationCriteria(item); + const ruleLocation = await this.allocation.resolveLocation(criteria); + const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + + if (!location) { + throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); + } + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0; + + await this.dataSource.transaction(async (manager) => { + const locked = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + this.assertTransition(locked.status, 'STORED'); + + if ( + locked.warehouseId !== location.warehouseId || + locked.yardId !== location.yardId || + locked.zoneId !== location.zoneId + ) { + const { warehouse, yard, zone } = await this.validateLocation(manager, location); + this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', yard, weight, volume, containerCount); + this.assertCapacity('Zone', zone, weight, volume, containerCount); + + await this.applyCapacityDelta( + manager, + { + warehouseId: locked.warehouseId, + yardId: locked.yardId, + zoneId: locked.zoneId, + }, + -weight, + -volume, + -containerCount, + ); + await this.applyCapacityDelta(manager, location, weight, volume, containerCount); + } + + await manager.getRepository(WarehouseInventory).update(id, { + status: 'STORED', + storedAt: new Date(), + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + notes: this.appendNote( + locked.notes, + ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, + ), + }); + + await this.activityLog.record( + { + activityType: 'INVENTORY_STORED', + inventoryId: id, + warehouseId: location.warehouseId, + description: ruleLocation?.rule + ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` + : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + performedBy, + }, + manager, + ); }); + + return this.findById(id); } async reserve(dto: ReserveInventoryDto): Promise { @@ -1204,6 +1399,80 @@ export class WarehouseInventoryService { return this.findById(id); } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + const item = await this.findById(id); + if (!item.releaseDate) { + throw new BadRequestException('A release order must be issued before downloading the exit paper'); + } + + const [row] = await this.dataSource.query( + `SELECT inv.id, + inv.release_order_reference AS "releaseOrderReference", + inv.release_date AS "releaseDate", + inv.quantity, + inv.weight, + inv.status, + b.id AS "bookingId", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + company.name AS "customerName", + container.container_number AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.name AS "zoneName", + zone.code AS "zoneCode" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON ( + (inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = b.id) + ) AND container.deleted_at IS NULL + LEFT JOIN freight.cargoes cargo ON ( + (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) + ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [id], + ); + + const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`; + const bookingReference = row?.bookingReference || item.bookingId || 'N/A'; + const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date(); + const html = this.buildReleaseDocumentHtml({ + reference, + issuedAt, + bookingReference, + bookingStatus: row?.bookingStatus ?? null, + customerName: row?.customerName ?? null, + freightType: row?.freightType ?? null, + tradeDirection: row?.tradeDirection ?? null, + containerNumber: row?.containerNumber ?? null, + cargoDescription: row?.cargoDescription ?? null, + quantity: Number(row?.quantity ?? item.quantity ?? 0), + weight: Number(row?.weight ?? item.weight ?? 0), + warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null, + yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, + zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, + inventoryStatus: row?.status ?? item.status, + }); + + return { + filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.pdfService.htmlToPdfBuffer(html), + }; + } + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ async deliver(id: string, dto: DeliverInventoryDto): Promise { const item = await this.findById(id); @@ -1226,7 +1495,17 @@ export class WarehouseInventoryService { }); // Goods physically leave the warehouse on pickup — free up capacity. - await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + await this.applyCapacityDelta( + manager, + { + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + }, + -weight, + -volume, + -containerCount, + ); // Proof of delivery is captured on the linked cargo. if (item.cargoId) { @@ -1361,97 +1640,48 @@ export class WarehouseInventoryService { }); } - async dispatch(id: string, performedBy?: string): Promise { - const item = await this.findById(id); - this.assertTransition(item.status, 'DISPATCHED'); - - const weight = Number(item.weight) || 0; - const volume = Number(item.volume) || 0; - const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(WarehouseInventory).update(id, { - status: 'DISPATCHED', - dispatchedAt: new Date(), - }); - // Item physically leaves the warehouse — free up capacity. - await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); - await this.activityLog.record( - { - activityType: 'INVENTORY_DISPATCHED', - inventoryId: id, - warehouseId: item.warehouseId, - description: 'Inventory dispatched', - performedBy, - }, - manager, - ); + dispatch(id: string, performedBy?: string): Promise { + return this.transition(id, 'DISPATCHED', { + timestampField: 'dispatchedAt', + activityType: 'INVENTORY_DISPATCHED', + description: 'Inventory dispatched', + performedBy, }); - - return this.findById(id); } - // ── Movement ────────────────────────────────────────────────────────────── - - async move(id: string, dto: MoveInventoryDto): Promise { - const item = await this.findById(id); - if (item.status === 'DISPATCHED') { - throw new BadRequestException('Dispatched inventory cannot be moved'); - } - - const weight = Number(item.weight) || 0; - const volume = Number(item.volume) || 0; - const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; - - const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }; - - await this.dataSource.transaction(async (manager) => { - const { warehouse } = await this.validateLocation(manager, dto); - - // Capacity check at the destination (item is added there). - const dest = await this.loadLocation(manager, dto); - this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount); - this.assertCapacity('Yard', dest.yard, weight, volume, containerCount); - this.assertCapacity('Zone', dest.zone, weight, volume, containerCount); - - // Free the old location, occupy the new one. - await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1); - await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); - - await manager.getRepository(WarehouseInventory).update(id, { - warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - }); - - await manager.getRepository(WarehouseInventoryMovement).save( - manager.getRepository(WarehouseInventoryMovement).create({ - inventoryId: id, - fromWarehouseId: from.warehouseId, - fromYardId: from.yardId, - fromZoneId: from.zoneId, - toWarehouseId: dto.warehouseId, - toYardId: dto.yardId, - toZoneId: dto.zoneId, - remarks: dto.remarks?.trim() ?? null, - movedBy: dto.movedBy ?? 'system', - movedAt: new Date(), - }), - ); - - await this.activityLog.record( - { - activityType: 'INVENTORY_MOVED', - inventoryId: id, - warehouseId: warehouse.id, - description: dto.remarks?.trim() || 'Inventory moved', - performedBy: dto.movedBy, - }, - manager, - ); + async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise { + const warehouses = await this.dataSource.getRepository(Warehouse).find({ + where: { + status: 'ACTIVE', + ...(filter.facilityId ? { stationId: filter.facilityId } : {}), + ...(filter.warehouseId ? { id: filter.warehouseId } : {}), + }, }); + const inventory = await this.findAll(filter); + const today = new Date(); - return this.findById(id); + const byStatus = inventory.reduce>((acc, item) => { + acc[item.status] = (acc[item.status] ?? 0) + 1; + return acc; + }, {}); + + return { + totalWarehouses: warehouses.length, + totalInventory: inventory.length, + receivedToday: inventory.filter((item) => { + const arrivedAt = item.arrivedAt ?? item.createdAt; + return ( + arrivedAt.getFullYear() === today.getFullYear() && + arrivedAt.getMonth() === today.getMonth() && + arrivedAt.getDate() === today.getDate() + ); + }).length, + stored: byStatus.STORED ?? 0, + reserved: byStatus.RESERVED ?? 0, + readyForLoading: byStatus.READY_FOR_LOADING ?? 0, + loaded: byStatus.LOADED ?? 0, + dispatched: byStatus.DISPATCHED ?? 0, + }; } findMovements(id: string): Promise { @@ -1468,6 +1698,145 @@ export class WarehouseInventoryService { // ── Inquiry (Batch 1) ────────────────────────────────────────────────── async inquiry(filter: InquiryWarehouseInventoryDto): Promise { + const bookingReference = (filter.bookingReference ?? filter.bookingNumber)?.trim(); + if (bookingReference) { + const params: unknown[] = [`%${bookingReference}%`]; + const where = ['b.reference ILIKE $1', 'b.deleted_at IS NULL']; + + if (filter.containerNumber?.trim()) { + params.push(`%${filter.containerNumber.trim()}%`); + where.push(`container.container_number ILIKE $${params.length}`); + } + if (filter.cargoType?.trim()) { + params.push(`%${filter.cargoType.trim()}%`); + where.push(`cargo_type.cargo_type_name ILIKE $${params.length}`); + } + if (filter.goodsName?.trim()) { + params.push(`%${filter.goodsName.trim()}%`); + where.push(`(inv.notes ILIKE $${params.length} OR cargo.description ILIKE $${params.length})`); + } + if (filter.warehouseId) { + params.push(filter.warehouseId); + where.push(`inv.warehouse_id = $${params.length}`); + } + if (filter.yardId) { + params.push(filter.yardId); + where.push(`inv.yard_id = $${params.length}`); + } + if (filter.zoneId) { + params.push(filter.zoneId); + where.push(`inv.zone_id = $${params.length}`); + } + if (filter.status) { + params.push(filter.status); + where.push(`inv.status = $${params.length}`); + } + + const rows = await this.dataSource.query( + `SELECT COALESCE(inv.id::text, b.id::text) AS "id", + inv.id AS "inventoryId", + b.id AS "bookingId", + b.reference AS "bookingReference", + b.reference AS "bookingNumber", + b.status AS "bookingStatus", + company.name AS "customerName", + container.container_number AS "containerNumber", + cargo_type.cargo_type_name AS "cargoType", + cargo.description AS "cargoDescription", + inv.goods_id AS "goodsId", + wh.id AS "warehouseId", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.id AS "yardId", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.id AS "zoneId", + zone.name AS "zoneName", + zone.code AS "zoneCode", + inv.status, + ts.train_number AS "trainNumber", + ts.status AS "trainStatus", + oy.code AS "originCode", + dy.code AS "destinationCode", + CASE + WHEN inv.id IS NOT NULL THEN concat_ws(' / ', wh.code, yard.code, zone.code) + WHEN ts.status = 'ARRIVED' THEN concat('Arrived at ', COALESCE(dy.code, 'destination'), ' - awaiting unload') + WHEN ts.status = 'DISPATCHED' THEN concat('In transit: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?')) + WHEN ts.id IS NOT NULL THEN concat('Scheduled: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?')) + ELSE 'No warehouse inventory yet' + END AS "locationSummary", + COALESCE(inv.quantity, 0) AS quantity, + COALESCE(inv.weight, b.cargo_total_weight_vgm, 0) AS weight, + inv.arrived_at AS "arrivedAt", + inv.ready_for_loading_at AS "readyForLoadingAt" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON ( + (inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = b.id) + ) AND container.deleted_at IS NULL + LEFT JOIN freight.cargoes cargo ON ( + (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) + ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN LATERAL ( + SELECT ts_inner.* + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts_inner ON ts_inner.id = tsb.train_schedule_id + WHERE tsb.booking_id = b.id + AND tsb.deleted_at IS NULL + AND ts_inner.deleted_at IS NULL + ORDER BY ts_inner.scheduled_departure_date DESC NULLS LAST + LIMIT 1 + ) ts ON TRUE + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ${where.join(' AND ')} + ORDER BY inv.created_at DESC NULLS LAST, b.created_at DESC`, + params, + ); + + return rows.map((row: Record) => ({ + id: String(row.id), + inventoryId: (row.inventoryId as string | null) ?? null, + bookingId: (row.bookingId as string | null) ?? null, + bookingReference: (row.bookingReference as string | null) ?? null, + bookingNumber: (row.bookingNumber as string | null) ?? null, + bookingStatus: (row.bookingStatus as string | null) ?? null, + customerName: (row.customerName as string | null) ?? null, + containerNumber: (row.containerNumber as string | null) ?? null, + cargoType: (row.cargoType as string | null) ?? null, + cargoDescription: (row.cargoDescription as string | null) ?? null, + goodsId: (row.goodsId as string | null) ?? null, + warehouse: row.warehouseId + ? { id: row.warehouseId as string, name: row.warehouseName as string, code: row.warehouseCode as string } + : null, + yard: row.yardId + ? { id: row.yardId as string, name: row.yardName as string, code: row.yardCode as string } + : null, + zone: row.zoneId + ? { id: row.zoneId as string, name: row.zoneName as string, code: row.zoneCode as string } + : null, + status: (row.status as string | null) ?? null, + trainNumber: (row.trainNumber as string | null) ?? null, + trainStatus: (row.trainStatus as string | null) ?? null, + route: + row.originCode || row.destinationCode + ? `${row.originCode ?? '?'} -> ${row.destinationCode ?? '?'}` + : null, + locationSummary: (row.locationSummary as string | null) ?? null, + quantity: Number(row.quantity) || 0, + weight: Number(row.weight) || 0, + arrivedAt: (row.arrivedAt as Date | null) ?? null, + readyForLoadingAt: (row.readyForLoadingAt as Date | null) ?? null, + })); + } + const qb = this.dataSource .getRepository(WarehouseInventory) .createQueryBuilder('inv') @@ -1476,8 +1845,20 @@ export class WarehouseInventoryService { .leftJoinAndSelect('inv.zone', 'zone') .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') - .leftJoin('freight.containers', 'container', 'container.id = inv.container_id') - .leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id') + .leftJoin( + 'freight.containers', + 'container', + `((inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id)) + AND container.deleted_at IS NULL`, + ) + .leftJoin( + 'freight.cargoes', + 'cargo', + `((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id)) + AND cargo.deleted_at IS NULL`, + ) .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') .addSelect('booking.reference', 'b_reference') .addSelect('company.name', 'c_name') @@ -1486,9 +1867,6 @@ export class WarehouseInventoryService { .addSelect('cargo_type.cargo_type_name', 'cgt_name') .orderBy('inv.created_at', 'DESC'); - if (filter.bookingNumber?.trim()) { - qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` }); - } if (filter.containerNumber?.trim()) { qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` }); } @@ -1509,8 +1887,11 @@ export class WarehouseInventoryService { const row = raw[index] ?? {}; return { id: inv.id, + inventoryId: inv.id, bookingId: inv.bookingId ?? null, + bookingReference: row.b_reference ?? null, bookingNumber: row.b_reference ?? null, + bookingStatus: null, customerName: row.c_name ?? null, containerNumber: row.ct_number ?? null, cargoType: row.cgt_name ?? null, @@ -1522,6 +1903,12 @@ export class WarehouseInventoryService { yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null, zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null, status: inv.status, + trainNumber: null, + trainStatus: null, + route: null, + locationSummary: inv.warehouse + ? [inv.warehouse.code, inv.yard?.code, inv.zone?.code].filter(Boolean).join(' / ') + : null, quantity: Number(inv.quantity), weight: Number(inv.weight), arrivedAt: inv.arrivedAt ?? null, @@ -1566,6 +1953,109 @@ export class WarehouseInventoryService { return this.findById(id); } + private buildReleaseDocumentHtml(data: { + reference: string; + issuedAt: Date; + bookingReference: string; + bookingStatus: string | null; + customerName: string | null; + freightType: string | null; + tradeDirection: string | null; + containerNumber: string | null; + cargoDescription: string | null; + quantity: number; + weight: number; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryStatus: string | null; + }): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const issuedAt = data.issuedAt.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const rows = [ + ['Booking reference', data.bookingReference], + ['Customer', data.customerName], + ['Booking status', data.bookingStatus], + ['Freight type', data.freightType], + ['Trade direction', data.tradeDirection], + ['Container number', data.containerNumber], + ['Cargo / goods', data.cargoDescription], + ['Quantity', data.quantity], + ['Weight', `${data.weight.toLocaleString()} kg`], + ['Warehouse', data.warehouse], + ['Yard', data.yard], + ['Zone', data.zone], + ['Inventory status', data.inventoryStatus], + ]; + + return ` + + + + Warehouse Release Exit Paper + + + +
+
+
+
EDR Warehouse Operations
+

Warehouse Release / Exit Paper

+
+
+ Release reference + ${esc(data.reference)} + Issued: ${esc(issuedAt)} +
+
+
+ This document authorizes the listed booking/goods to leave the warehouse after release checks. +
+ + + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
+
Warehouse officer name / signature / date
+
Customer or driver name / signature / date
+
+ +
+ +`; + } + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { throw new BadRequestException(`Invalid transition ${from} → ${to}`); @@ -1574,22 +2064,7 @@ export class WarehouseInventoryService { private async validateLocation( manager: EntityManager, - dto: { warehouseId: string; yardId: string; zoneId: string }, - ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { - const { warehouse, yard, zone } = await this.loadLocation(manager, dto); - - if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE'); - if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse'); - if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE'); - if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard'); - if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE'); - - return { warehouse, yard, zone }; - } - - private async loadLocation( - manager: EntityManager, - dto: { warehouseId: string; yardId: string; zoneId: string }, + dto: LocationRef, ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } }); if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); @@ -1610,12 +2085,210 @@ export class WarehouseInventoryService { } } + private appendNote(existing: string | null | undefined, note: string): string { + const trimmed = existing?.trim(); + return trimmed ? `${trimmed}\n${note}` : note; + } + + private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise { + const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null; + + if (!item.bookingId) { + return { + freightType: fallbackFreightType, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + const [row]: Array<{ + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + containerStatus: string | null; + originCountry: string | null; + destinationCountry: string | null; + }> = await this.dataSource.query( + `SELECT b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode", + COALESCE(selected_container.status, booking_container.status) AS "containerStatus", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.containers selected_container + ON selected_container.id = $2 AND selected_container.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT c.status + FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.created_at ASC + LIMIT 1 + ) booking_container ON true + WHERE b.id = $1 AND b.deleted_at IS NULL + LIMIT 1`, + [item.bookingId, item.containerId], + ); + + if (!row) { + return { + freightType: fallbackFreightType, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + const derivedDirection = deriveTradeDirection( + { country: row.originCountry }, + { country: row.destinationCountry }, + ); + + return { + freightType: row.freightType ?? fallbackFreightType, + tradeDirection: row.tradeDirection ?? derivedDirection, + cargoTypeCode: row.cargoTypeCode, + containerStatus: row.containerStatus, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + private yardTypeFor(criteria: InventoryAllocationCriteria): string { + const freightType = criteria.freightType?.toUpperCase(); + if (freightType === 'CONTAINER') return 'CONTAINER_YARD'; + if (freightType === 'BULK') return 'BULK_YARD'; + return 'GENERAL_CARGO_YARD'; + } + + private zoneTypeFor(criteria: InventoryAllocationCriteria): string { + const freightType = criteria.freightType?.toUpperCase(); + if (freightType === 'CONTAINER') return 'CONTAINER_ZONE'; + if (freightType === 'BULK') return 'BULK_ZONE'; + return 'GENERAL_CARGO_ZONE'; + } + + private async pickCapacityBalancedStorageLocation( + item: WarehouseInventory, + criteria: InventoryAllocationCriteria, + ): Promise { + const weight = Number(item.weight) || 0; + const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0; + const yardType = this.yardTypeFor(criteria); + const zoneType = this.zoneTypeFor(criteria); + + const query = async (warehouseId: string | null) => { + const [row]: Array<{ + warehouseId: string; + facilityId: string | null; + warehouseName: string | null; + yardId: string; + yardName: string | null; + yardCode: string | null; + zoneId: string; + zoneName: string | null; + zoneCode: string | null; + }> = await this.dataSource.query( + `SELECT wh.id AS "warehouseId", + wh.facility_id AS "facilityId", + wh.name AS "warehouseName", + yard.id AS "yardId", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.id AS "zoneId", + zone.name AS "zoneName", + zone.code AS "zoneCode" + FROM freight.warehouses wh + JOIN freight.warehouse_yards yard + ON yard.warehouse_id = wh.id + AND yard.deleted_at IS NULL + AND yard.status = 'ACTIVE' + AND yard.is_active = true + JOIN freight.warehouse_zones zone + ON zone.yard_id = yard.id + AND zone.deleted_at IS NULL + AND zone.status = 'ACTIVE' + AND zone.is_active = true + WHERE wh.deleted_at IS NULL + AND wh.status = 'ACTIVE' + AND wh.is_active = true + AND ($1::uuid IS NULL OR wh.id = $1::uuid) + AND (COALESCE(yard.max_weight, yard.capacity_weight) IS NULL + OR yard.current_weight::numeric + $4::numeric <= COALESCE(yard.max_weight, yard.capacity_weight)) + AND (COALESCE(zone.max_weight, zone.capacity_weight) IS NULL + OR zone.current_weight::numeric + $4::numeric <= COALESCE(zone.max_weight, zone.capacity_weight)) + AND (yard.capacity_containers IS NULL + OR yard.current_containers + $5::int <= yard.capacity_containers) + AND (zone.capacity_containers IS NULL + OR zone.current_containers + $5::int <= zone.capacity_containers) + ORDER BY + CASE WHEN yard.type = $2 THEN 0 ELSE 1 END, + CASE WHEN zone.type = $3 THEN 0 ELSE 1 END, + ( + CASE WHEN yard.capacity_weight IS NULL OR yard.capacity_weight = 0 THEN 0 + ELSE yard.current_weight::numeric / yard.capacity_weight::numeric END + + + CASE WHEN yard.capacity_containers IS NULL OR yard.capacity_containers = 0 THEN 0 + ELSE yard.current_containers::numeric / yard.capacity_containers::numeric END + + + CASE WHEN zone.capacity_weight IS NULL OR zone.capacity_weight = 0 THEN 0 + ELSE zone.current_weight::numeric / zone.capacity_weight::numeric END + + + CASE WHEN zone.capacity_containers IS NULL OR zone.capacity_containers = 0 THEN 0 + ELSE zone.current_containers::numeric / zone.capacity_containers::numeric END + ) ASC, + yard.code ASC, + zone.code ASC + LIMIT 1`, + [warehouseId, yardType, zoneType, weight, containerCount], + ); + return row; + }; + + const row = (await query(item.warehouseId)) ?? (await query(null)); + if (!row) return null; + + return { + warehouseId: row.warehouseId, + facilityId: row.facilityId, + yardId: row.yardId, + zoneId: row.zoneId, + rule: null, + path: [row.warehouseName, row.yardCode ?? row.yardName, row.zoneCode ?? row.zoneName] + .filter(Boolean) + .join(' -> '), + }; + } + private async getBookingStatus(bookingId: string): Promise { - const rows = await this.dataSource.query( + const [row]: Array<{ status: string | null }> = await this.dataSource.query( 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', [bookingId], ); - return rows?.[0]?.status ?? null; + return row?.status ?? null; + } + + private async attachBookingSummaries(items: WarehouseInventory[]): Promise { + const bookingIds = [...new Set(items.map((item) => item.bookingId).filter(Boolean))] as string[]; + if (bookingIds.length === 0) return; + + const rows: BookingSummaryRow[] = await this.dataSource.query( + `SELECT b.id, b.reference, b.status, company.name AS customer + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, + [bookingIds], + ); + const summaries = new Map(rows.map((row) => [row.id, row])); + + items.forEach((item) => { + const summary = item.bookingId ? summaries.get(item.bookingId) : undefined; + if (!summary) return; + Object.assign(item, { + bookingReference: summary.reference, + bookingStatus: summary.status, + customerName: summary.customer, + }); + }); } /** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */ @@ -1665,25 +2338,24 @@ export class WarehouseInventoryService { private async applyCapacityDelta( manager: EntityManager, - warehouseId: string, - yardId: string, - zoneId: string, - weight: number, - volume: number, - containers: number, - sign: 1 | -1, + location: LocationRef, + weightAdd: number, + volumeAdd: number, + containerAdd: number, ): Promise { - const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager); const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [ - [Warehouse, warehouseId], - [WarehouseYard, yardId], - [WarehouseZone, zoneId], + [Warehouse, location.warehouseId], + [WarehouseYard, location.yardId], + [WarehouseZone, location.zoneId], ]; for (const [entity, id] of targets) { - if (weight) await apply(entity, { id }, 'currentWeight', weight); - if (volume) await apply(entity, { id }, 'currentVolume', volume); - if (containers) await apply(entity, { id }, 'currentContainers', containers); + if (weightAdd > 0) await manager.increment(entity, { id }, 'currentWeight', weightAdd); + if (weightAdd < 0) await manager.decrement(entity, { id }, 'currentWeight', Math.abs(weightAdd)); + if (volumeAdd > 0) await manager.increment(entity, { id }, 'currentVolume', volumeAdd); + if (volumeAdd < 0) await manager.decrement(entity, { id }, 'currentVolume', Math.abs(volumeAdd)); + if (containerAdd > 0) await manager.increment(entity, { id }, 'currentContainers', containerAdd); + if (containerAdd < 0) await manager.decrement(entity, { id }, 'currentContainers', Math.abs(containerAdd)); } } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index d58a74b7d..abe514d80 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -75,9 +75,9 @@ export class WarehouseInvoiceService { feeType, description: p.ruleType === 'STORAGE_FEE' - ? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free` - : `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`, - quantity: p.chargeableDays, + ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` + : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, + quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, currency: p.currency, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 3ee0dde82..c14cea7a4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -15,6 +15,12 @@ export class WarehouseYardsController { private readonly zonesService: WarehouseZonesService, ) {} + @Get() + @ApiOperation({ summary: 'List all warehouse yards' }) + findAll() { + return this.yardsService.findAll(); + } + @Get(':id') @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index f65e4593e..3279e9092 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -13,6 +13,13 @@ export class WarehouseYardsService { private readonly warehousesService: WarehousesService, ) {} + findAll(): Promise { + return this.yardsRepository.findAll({ + relations: { warehouse: true, zones: true }, + order: { code: 'ASC' }, + }); + } + findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 30c4407f6..7d51feac3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service'; export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} + @Get() + @ApiOperation({ summary: 'List all warehouse zones' }) + findAll() { + return this.zonesService.findAll(); + } + @Get(':id') @ApiOperation({ summary: 'Get warehouse zone by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index a2f3800cd..b4ae2e0de 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -13,6 +13,13 @@ export class WarehouseZonesService { private readonly yardsService: WarehouseYardsService, ) {} + findAll(): Promise { + return this.zonesRepository.findAll({ + relations: { yard: { warehouse: true } }, + order: { code: 'ASC' }, + }); + } + findByYard(yardId: string): Promise { return this.zonesRepository.findAll({ where: { yardId }, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 4a08d7f28..02e3bbb4c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { FilesModule } from '../files/files.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; @@ -100,6 +101,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseInvoiceService, WarehouseSchedulingAdapterService, SchedulingReadFacade, + ContractPdfService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts index f9e4e2af7..c5a49e629 100644 --- a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -102,6 +102,12 @@ export class Batch5TestDataSeeder { serviceTypeId: serviceType.id, status: 'PAID', paymentStatus: 'PAID', + scheduledDate: now, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, tradeDirection: 'EXPORT', freightType: 'BULK', cargoTotalWeightVgm: seed.weight, diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index e00544dc0..fbc19100a 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -89,6 +89,7 @@ export class WarehouseDemoSeeder { ): Promise => bookingRepo.save( bookingRepo.create({ + ...this.demoBookingDefaults(), reference, originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, @@ -237,6 +238,7 @@ export class WarehouseDemoSeeder { for (let i = 1; i <= 3; i++) { const b = await bookingRepo.save( bookingRepo.create({ + ...this.demoBookingDefaults(), reference: `WH-DEMO-ARR-${i}`, originYardId: djibYard.id, destinationYardId: ethYard.id, @@ -255,4 +257,15 @@ export class WarehouseDemoSeeder { ); } } + + private demoBookingDefaults(): Partial { + return { + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + }; + } } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx index ae360c609..bb40823d8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx @@ -1,77 +1,16 @@ -import { useMemo, useState } from "react"; -import { ArrowRight, Building2, Package } from "lucide-react"; -import { - Accordion, - Badge, - Button, - Checkbox, - Group, - Paper, - Stack, - Text, - Title, -} from "@mantine/core"; +import { useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, Calendar, Package, User } from "lucide-react"; +import { Group } from "@mantine/core"; +import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; -import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; +import { bookingTable } from "@/components/bookings/booking-ui.styles"; import type { BookingListRow } from "@/types/booking"; -import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue"; - -function BookingQueueRow({ - booking, - selected, - disabled, - onToggle, -}: { - booking: BookingListRow; - selected: boolean; - disabled: boolean; - onToggle: () => void; -}) { - return ( - - - - - - {booking.reference} - {booking.isGovernment ? ( - }> - Government - - ) : null} - {booking.freightType} - {booking.schedulingStatus ? ( - {booking.schedulingStatus} - ) : null} - - {booking.customerLabel} - - {booking.originLabel} - - {booking.destinationLabel} - - - - {booking.serviceTypeLabel ? ( - - {booking.serviceTypeLabel} - {booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""} - - ) : null} - - - - ); -} +import { cn } from "@/lib/utils"; +import { Badge, DataTable, type ColumnDef } from "@edr/ui-common"; export function OperationsBookingQueue({ bookings, @@ -82,144 +21,144 @@ export function OperationsBookingQueue({ isLoading?: boolean; onAllocate: (bookingIds: string[]) => void; }) { - const { government, commercial } = useMemo( - () => groupBookingsForOperationsQueue(bookings), - [bookings], + const navigate = useNavigate(); + const suppressRowClickRef = useRef(false); + + const suppressRowClick = useCallback(() => { + suppressRowClickRef.current = true; + window.setTimeout(() => { + suppressRowClickRef.current = false; + }, 400); + }, []); + + const handleRowClick = useCallback( + (row: BookingListRow) => { + if (suppressRowClickRef.current) return; + navigate(`/dashboard/booking-requests/${row.id}`); + }, + [navigate], ); - const [govSelected, setGovSelected] = useState([]); - const [selectedByBucket, setSelectedByBucket] = useState>({}); - const allocatable = (row: BookingListRow) => - row.status === "PAID" && - canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus }); - - const govSelection = govSelected.length - ? govSelected - : government.filter(allocatable).map((b) => b.id); - - const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => { - const existing = selectedByBucket[bucketKey]; - if (existing) return existing; - return bucketBookings.filter(allocatable).map((b) => b.id); - }; - - const toggleGov = (bookingId: string) => { - setGovSelected((prev) => { - const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id); - return base.includes(bookingId) - ? base.filter((id) => id !== bookingId) - : [...base, bookingId]; - }); - }; - - const toggleBucket = (bucketKey: string, bookingId: string) => { - setSelectedByBucket((prev) => { - const current = prev[bucketKey] ?? []; - const next = current.includes(bookingId) - ? current.filter((id) => id !== bookingId) - : [...current, bookingId]; - return { ...prev, [bucketKey]: next }; - }); - }; - - if (isLoading) { - return Loading operations queue…; - } - - if (!government.length && !commercial.length) { - return ( - - No PAID bookings ready to allocate. - - ); - } + const columns: ColumnDef[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ +
+
+ +

{booking.reference}

+ {booking.isGovernment ? ( + + Government + + ) : null} +
+

+ + {booking.customerLabel} +

+
+
+ ); + }, + }, + { + id: "route", + header: () => Route, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ {booking.originLabel} + + {booking.destinationLabel} +
+
+ + {booking.tradeDirection} + + + {booking.freightType} + +
+
+ ); + }, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( +
+ + {row.original.schedulingStatus ? ( + + ) : null} +
+ ), + }, + { + id: "scheduled", + header: () => Scheduled, + cell: ({ row }) => ( + + + {row.original.scheduledDate} + + ), + }, + { + id: "priority", + header: () => Priority, + cell: ({ row }) => , + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {row.original.paymentCurrency}{" "} + {row.original.totalAmount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + ), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( + onAllocate([row.original.id])} + /> + ), + }, + ]; return ( - - {government.length > 0 ? ( - - - - Government priority - - Served first — not grouped by 3-hour window - - - - {govSelection.length} selected - - - - - {government.map((booking) => ( - toggleGov(booking.id)} - /> - ))} - - - ) : null} - - {commercial.length > 0 ? ( - - {commercial.map((bucket) => { - const selected = bucketSelection(bucket.key, bucket.bookings); - return ( - - - - - {bucket.label} - - {bucket.bookings.length} commercial booking - {bucket.bookings.length === 1 ? "" : "s"} - - - - {selected.length} selected - - - - - - - {bucket.bookings.map((booking) => ( - toggleBucket(bucket.key, booking.id)} - /> - ))} - - - - ); - })} - - ) : null} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index cb1172def..b400ddba4 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -106,6 +106,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage route definitions built from freight yards", }, }, + { + prefix: "/dashboard/warehouses/list", + meta: { + title: "Warehouses", + subtitle: "Manage warehouses, yards, and zones", + }, + }, + { + prefix: "/dashboard/warehouse-inventory", + meta: { + title: "Warehouse inventory", + subtitle: "Track received items through inspection and loading", + }, + }, + { + prefix: "/dashboard/inventory-inquiry", + meta: { + title: "Inventory inquiry", + subtitle: "Locate cargo, containers, and goods inside the warehouse network", + }, + }, + { + prefix: "/dashboard/warehouses", + meta: { + title: "Warehouse dashboard", + subtitle: "Live overview of warehouse capacity and inventory lifecycle", + }, + }, ...getFleetRouteMeta(), { prefix: "/dashboard/trains/", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 73ea669be..56a0d950c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -5,8 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; import { extractErrorMessage } from './options'; -import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse'; +import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse'; +import { openPdfBlob } from './pdf'; const INVOICE_STATUS_COLOR: Record = { DRAFT: 'gray', @@ -32,6 +34,9 @@ function fmtDate(iso: string | null) { return new Date(iso).toLocaleDateString(); } +const money = (amount: number, currency: string) => + `${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; + function FeeCard({ fee }: { fee: FeePreview }) { const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' }; const configured = Boolean(fee.ruleId); @@ -48,7 +53,7 @@ function FeeCard({ fee }: { fee: FeePreview }) { )} - {fee.amount.toLocaleString()} {fee.currency} + {money(fee.amount, fee.currency)} @@ -60,10 +65,12 @@ function FeeCard({ fee }: { fee: FeePreview }) { - + + + )} @@ -106,7 +113,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa if (!inventoryId) return; try { const inv = await generate.mutateAsync({ inventoryId, confirmZero }); - toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} — ${inv.totalAmount} ${inv.currency}` }); + toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` }); } catch (error) { const msg = extractErrorMessage(error); if (/no payable warehouse fee/i.test(msg)) { @@ -121,11 +128,21 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa const handleGateClearance = async () => { if (!inventoryId) return; + const pdfWindow = window.open('', '_blank'); try { - await gateClear.mutateAsync(inventoryId); - toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' }); + const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem; + const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); + const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; + const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The release PDF opened in a browser tab.' + : 'The browser blocked the preview tab, so the PDF was downloaded.', + }); onClose(); } catch (error) { + pdfWindow?.close(); toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) }); } }; @@ -165,7 +182,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa - {Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due + {money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx index d56547455..2c9098ad3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button, Divider, @@ -12,10 +12,8 @@ import { } from '@mantine/core'; import { Upload } from 'lucide-react'; -import { useMutation } from '@tanstack/react-query'; - -import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses'; import { INSPECTION_REPORT_TYPES, INSPECTION_STATUSES, @@ -47,12 +45,9 @@ const STATUS_LABELS: Record = { /** Batch 4.5 — record an inspection / damage report with optional image upload. */ export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) { const { toast } = useToast(); - const createReport = useMutation( - api.warehouses.createInspectionReport.mutationOptions(), - ); - const uploadAttachments = useMutation( - api.warehouses.uploadInspectionAttachments.mutationOptions(), - ); + const createReport = useCreateInspectionReport(); + const uploadAttachments = useUploadInspectionAttachments(); + const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined); const [reportType, setReportType] = useState('INSPECTION'); const [inspectionStatus, setInspectionStatus] = useState('PASSED'); @@ -82,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti setFiles([]); }; + useEffect(() => { + if (!opened) return; + const report = reportsQuery.data?.[0]; + if (!report) { + reset(); + return; + } + + setReportType(report.reportType); + setInspectionStatus(report.inspectionStatus); + setHasDamage(report.hasDamage ?? false); + setDamageDescription(report.damageDescription ?? ''); + setHasWeightLoss(report.hasWeightLoss ?? false); + setExpectedWeight(report.expectedWeight == null ? '' : Number(report.expectedWeight)); + setActualWeight(report.actualWeight == null ? '' : Number(report.actualWeight)); + setHasMissingItems(report.hasMissingItems ?? false); + setMissingItemsDescription(report.missingItemsDescription ?? ''); + setRemarks(report.remarks ?? ''); + setFiles([]); + }, [opened, reportsQuery.data]); + const handleSubmit = async () => { if (!inventoryId) return; try { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx new file mode 100644 index 000000000..ad079577b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx @@ -0,0 +1,91 @@ +import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core'; + +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; +import { formatDate, formatNumber } from './options'; + +interface InventoryDetailModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + + {value || '-'} + + + ); +} + +export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { + return ( + + {!item ? ( + No inventory item selected. + ) : ( + + + + + {item.booking?.reference ?? item.bookingId ?? item.id} + + + Inventory ID: {item.id} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {item.inspectionStatus ?? 'Not inspected'}} /> + + + + + + + + + + + + + + {item.notes && ( + <> + + {item.notes} + + )} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx new file mode 100644 index 000000000..479249894 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryInquiryDetailModal.tsx @@ -0,0 +1,92 @@ +import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core'; + +import type { InventoryInquiryResult } from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; +import { formatDate, formatNumber } from './options'; + +interface InventoryInquiryDetailModalProps { + opened: boolean; + onClose: () => void; + result: InventoryInquiryResult | null; +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + + {value || '-'} + + + ); +} + +function itemLabel(result: InventoryInquiryResult) { + if (result.containerNumber) return `Container ${result.containerNumber}`; + if (result.cargoType) return result.cargoType; + if (result.cargoDescription) return result.cargoDescription; + if (result.goodsId) return `Goods ${result.goodsId}`; + return '-'; +} + +export function InventoryInquiryDetailModal({ opened, onClose, result }: InventoryInquiryDetailModalProps) { + return ( + + {!result ? ( + No inquiry result selected. + ) : ( + + + + + {result.bookingReference ?? result.bookingNumber ?? result.bookingId ?? result.id} + + + Inventory ID: {result.inventoryId ?? 'Not yet in warehouse inventory'} + + + {result.status ? ( + + ) : ( + + {result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index b076510ab..c69696bec 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -6,10 +6,12 @@ import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; +import { InventoryDetailModal } from './InventoryDetailModal'; import { InventoryHistoryModal } from './InventoryHistoryModal'; import { LoadInventoryModal } from './LoadInventoryModal'; import { MoveInventoryModal } from './MoveInventoryModal'; @@ -17,6 +19,7 @@ import { ReleaseOrderModal } from './ReleaseOrderModal'; import { ReserveInventoryModal } from './ReserveInventoryModal'; import { WarehouseInventoryTable } from './WarehouseInventoryTable'; import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; interface InventoryWorkbenchProps { items: WarehouseInventoryItem[]; @@ -33,6 +36,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo const [reserveItem, setReserveItem] = useState(null); const [loadItem, setLoadItem] = useState(null); const [historyItem, setHistoryItem] = useState(null); + const [viewItem, setViewItem] = useState(null); const [inspectItem, setInspectItem] = useState(null); const [feeItem, setFeeItem] = useState(null); const [releaseItem, setReleaseItem] = useState(null); @@ -91,10 +95,46 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo } }; + const downloadReleaseDocument = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadReleaseDocument(item.id); + const blob = response.data; + const filename = `release-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`; + const opened = openPdfBlob(blob, filename, pdfWindow); + toast({ title: opened ? 'Release exit paper opened' : 'Release exit paper downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Release paper preview failed', + description: extractErrorMessage(error), + }); + } finally { + setBusyId(null); + } + }; + + const storeInventory = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + try { + const stored = await storeMutation.mutateAsync(item.id); + toast({ + title: 'Inventory stored', + description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '), + }); + } catch (error) { + toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + const advance = (item: WarehouseInventoryItem, action: InventoryAction) => { switch (action) { case 'store': - return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored'); + return storeInventory(item); case 'reserve': setReserveItem(item); return; @@ -151,8 +191,10 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onAdvance={advance} onMove={setMoveItem} onHistory={setHistoryItem} + onView={setViewItem} onInspect={setInspectItem} onFeePreview={setFeeItem} + onReleaseDocument={downloadReleaseDocument} onLastMile={onLastMile} selectedIds={selected} onToggleSelect={toggleSelect} @@ -174,6 +216,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onClose={() => setHistoryItem(null)} item={historyItem} /> + setViewItem(null)} item={viewItem} /> setInspectItem(null)} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index f2edd620b..7cbac0e6d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -6,8 +6,10 @@ import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; interface ReleaseOrderModalProps { opened: boolean; @@ -19,6 +21,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const [reference, setReference] = useState(''); + const [downloading, setDownloading] = useState(false); useEffect(() => { if (opened) setReference(item?.releaseOrderReference ?? ''); @@ -26,36 +29,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr const handleSubmit = async () => { if (!item) return; + const pdfWindow = window.open('', '_blank'); try { - await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } }); - toast({ title: 'Release order issued' }); + const released = await releaseMutation.mutateAsync({ + id: item.id, + payload: { reference: reference.trim() || undefined }, + }); + setDownloading(true); + const response = await warehouseService.downloadReleaseDocument(item.id); + const blob = response.data; + const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`; + const opened = openPdfBlob(blob, filename, pdfWindow); + toast({ + title: 'Release exit paper issued', + description: opened + ? 'The PDF opened in a browser tab for printing or saving.' + : 'The browser blocked the preview tab, so the PDF was downloaded.', + }); onClose(); } catch (error) { + pdfWindow?.close(); toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) }); + } finally { + setDownloading(false); } }; return ( - + } color="orange" variant="light"> - Records the delivery order / release order sent to the customer. Once issued, the goods can be - picked up and delivered. + Creates the warehouse release document with booking, customer, cargo and location details. The + printed paper authorizes the goods to leave the warehouse gate. setReference(e.currentTarget.value)} /> - - diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx index bd76b5e10..c6baed7ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core'; -import { Building2, Eye, MapPin, Pencil } from 'lucide-react'; +import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core'; +import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -35,56 +35,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV return ( {warehouses.map((warehouse) => ( - - + + + -
- {warehouse.name} - - {warehouse.code} - -
- -
- - - - - - {warehouse.stationId && stationNameById.get(warehouse.stationId) && ( - - - {stationNameById.get(warehouse.stationId)} + + + + + + + {warehouse.name} + + + {warehouse.code} + + - )} - {warehouse.locationName && ( - - - {warehouse.locationName} - - )} - - - - Weight - - {formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)} - - - - Containers - - {formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)} + + + + - - onView(warehouse)} title="View"> - - - onEdit(warehouse)} title="Edit"> - - + + {warehouse.stationId && stationNameById.get(warehouse.stationId) && ( + + + + {stationNameById.get(warehouse.stationId)} + + + )} + + {warehouse.locationName && ( + + + + {warehouse.locationName} + + + )} + + + + + + + + + + + + onView(warehouse)} aria-label="View warehouse"> + + + + + onEdit(warehouse)} aria-label="Edit warehouse"> + + +
@@ -92,3 +137,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
); } + +const capacityPercent = (current?: number | null, capacity?: number | null) => { + if (!capacity || capacity <= 0) return 0; + return Math.min(100, Math.max(0, ((current ?? 0) / capacity) * 100)); +}; + +function CapacityRow({ + icon: Icon, + label, + current, + capacity, +}: { + icon: typeof Weight; + label: string; + current?: number | null; + capacity?: number | null; +}) { + const percent = capacityPercent(current, capacity); + const color = percent >= 90 ? 'red' : percent >= 70 ? 'orange' : 'green'; + + return ( + + + + + + {label} + + + + {formatCapacity(Number(current) || 0, capacity)} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx index 366a3950c..ecae5c2e8 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx @@ -1,5 +1,5 @@ -import { Stack, Text } from '@mantine/core'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core'; +import { Eye } from 'lucide-react'; import type { InventoryInquiryResult } from '@/types/warehouse'; import { InventoryStatusBadge } from './badges'; @@ -7,71 +7,111 @@ import { formatDate, formatNumber } from './options'; interface WarehouseInquiryTableProps { results: InventoryInquiryResult[]; + onView?: (result: InventoryInquiryResult) => void; } +const dash = '-'; + const itemDescriptor = (result: InventoryInquiryResult) => { if (result.containerNumber) return `Container ${result.containerNumber}`; - if (result.cargoType) return `Cargo · ${result.cargoType}`; - if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`; + if (result.cargoType) return `Cargo - ${result.cargoType}`; + if (result.cargoDescription) return `Cargo - ${result.cargoDescription}`; if (result.goodsId) return 'Goods'; - return '—'; + return dash; }; -const columns: ColumnDef[] = [ - { - id: 'booking', - header: 'Booking', - cell: ({ row }) => ( - - {row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)} +export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) { + if (results.length === 0) { + return ( + + No matching items. Adjust your search to locate cargo, containers or goods. - ), - }, - { id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' }, - { id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) }, - { - id: 'warehouse', - header: 'Warehouse', - cell: ({ row }) => ( - - {row.original.warehouse?.name ?? '—'} - {row.original.warehouse?.code && ( - - {row.original.warehouse.code} - - )} - - ), - }, - { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' }, - { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' }, - { - id: 'status', - header: 'Status', - cell: ({ row }) => , - }, - { id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) }, - { id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) }, - { - id: 'arrived', - header: 'Arrived', - cell: ({ row }) => {formatDate(row.original.arrivedAt)}, - }, - { - id: 'ready', - header: 'Ready', - cell: ({ row }) => {formatDate(row.original.readyForLoadingAt)}, - }, -]; + ); + } -export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) { return ( - + + + + + Booking + Customer + Item + Warehouse + Yard + Zone + Location + Status + Qty + Weight + Arrived + Ready + {onView ? Actions : null} + + + + {results.map((result) => ( + + + + {result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? dash} + + + {result.customerName ?? dash} + {itemDescriptor(result)} + + + {result.warehouse?.name ?? dash} + {result.warehouse?.code ? ( + + {result.warehouse.code} + + ) : null} + + + {result.yard?.name ?? dash} + {result.zone?.name ?? dash} + + + {result.locationSummary ?? dash} + {result.trainNumber ? ( + + {result.trainNumber} + {result.route ? ` - ${result.route}` : ''} + + ) : null} + + + + {result.status ? ( + + ) : ( + + {result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'} + + )} + + {formatNumber(result.quantity)} + {formatNumber(result.weight)} + + {formatDate(result.arrivedAt)} + + + {formatDate(result.readyForLoadingAt)} + + {onView ? ( + + + onView(result)} ml="auto"> + + + + + ) : null} + + ))} + +
+
); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 5598870c4..272d9bc62 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -1,15 +1,13 @@ -import { DataTable, type ColumnDef } from "@edr/ui-common"; -import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core"; -import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react"; -import { useMemo } from "react"; +import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core'; +import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react'; import { - INVENTORY_NEXT_ACTION, - type InventoryAction, - type WarehouseInventoryItem, -} from "@/types/warehouse"; -import { InventoryStatusBadge } from "./badges"; -import { formatDate, formatNumber, humanizeEnum } from "./options"; + getNextInventoryAction, + type InventoryAction, + type WarehouseInventoryItem, +} from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; +import { formatDate, formatNumber, humanizeEnum } from './options'; interface WarehouseInventoryTableProps { items: WarehouseInventoryItem[]; @@ -17,11 +15,11 @@ interface WarehouseInventoryTableProps { onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void; onMove: (item: WarehouseInventoryItem) => void; onHistory: (item: WarehouseInventoryItem) => void; + onView?: (item: WarehouseInventoryItem) => void; onInspect?: (item: WarehouseInventoryItem) => void; onFeePreview?: (item: WarehouseInventoryItem) => void; - // Optional Last Mile action — only rendered for items whose booking requested door delivery. + onReleaseDocument?: (item: WarehouseInventoryItem) => void; onLastMile?: (item: WarehouseInventoryItem) => void; - // Optional row selection (used for bulk Mark-as-Inspected). selectedIds?: Set; onToggleSelect?: (id: string) => void; onToggleSelectAll?: () => void; @@ -30,21 +28,21 @@ interface WarehouseInventoryTableProps { } const itemKind = (item: WarehouseInventoryItem) => { - if (item.containerId) return { label: "Container", color: "blue" }; - if (item.cargoId) return { label: "Cargo", color: "grape" }; - if (item.goodsId) return { label: "Goods", color: "orange" }; - return { label: "—", color: "gray" }; + if (item.containerId) return { label: 'Container', color: 'blue' }; + if (item.cargoId) return { label: 'Cargo', color: 'grape' }; + if (item.goodsId) return { label: 'Goods', color: 'orange' }; + return { label: '-', color: 'gray' }; }; const actionColor: Record = { - store: "blue", - reserve: "grape", - "ready-for-loading": "cyan", - load: "teal", - dispatch: "edr-green", - "ready-for-pickup": "orange", - release: "yellow", - deliver: "green", + store: 'blue', + reserve: 'grape', + 'ready-for-loading': 'cyan', + load: 'teal', + dispatch: 'edr-green', + 'ready-for-pickup': 'orange', + release: 'yellow', + deliver: 'green', }; export function WarehouseInventoryTable({ @@ -53,165 +51,195 @@ export function WarehouseInventoryTable({ onAdvance, onMove, onHistory, + onView, onInspect, onFeePreview, + onReleaseDocument, + onLastMile, + selectedIds, + onToggleSelect, + onToggleSelectAll, + allSelected, + someSelected, }: WarehouseInventoryTableProps) { - const columns = useMemo[]>( - () => [ - { - id: "booking", - header: "Booking", - cell: ({ row }) => - row.original.bookingId ? ( - - - {row.original.bookingId.slice(0, 8)}… - - - ) : ( - - — - - ), - }, - { - id: "facility", - header: "Facility", - cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—", - }, - { - id: "warehouse", - header: "Warehouse", - cell: ({ row }) => row.original.warehouse?.code ?? "—", - }, - { - id: "yard", - header: "Yard", - cell: ({ row }) => row.original.yard?.code ?? "—", - }, - { - id: "zone", - header: "Zone", - cell: ({ row }) => row.original.zone?.code ?? "—", - }, - { - id: "item", - header: "Item", - cell: ({ row }) => { - const kind = itemKind(row.original); - return ( - - {kind.label} - - ); - }, - }, - { - id: "qty", - header: "Qty", - cell: ({ row }) => formatNumber(row.original.quantity), - }, - { - id: "weight", - header: "Weight", - cell: ({ row }) => formatNumber(row.original.weight), - }, - { - id: "status", - header: "Status", - cell: ({ row }) => ( - - ), - }, - { - id: "arrived", - header: "Arrived", - cell: ({ row }) => ( - {formatDate(row.original.arrivedAt)} - ), - }, - { - id: "actions", - header: "", - cell: ({ row }) => { - const item = row.original; - const busy = busyId === item.id; - const nextAction = INVENTORY_NEXT_ACTION[item.status]; - return ( - e.stopPropagation()} - > - {nextAction && ( - - )} - {item.status !== "DISPATCHED" && ( - - onMove(item)} - > - - - - )} - {onInspect && ( - - onInspect(item)} - > - - - - )} - {onFeePreview && ( - - onFeePreview(item)} - > - - - - )} - - onHistory(item)} - > - - - - - ); - }, - }, - ], - [busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview], - ); + const selectable = Boolean(onToggleSelect); + + if (items.length === 0) { + return ( + + No inventory items found. + + ); + } return ( - + + + + + {selectable && ( + + + + )} + Booking + Facility + Warehouse + Yard + Zone + Item + Qty + Weight + Status + Arrived + Actions + + + + {items.map((item) => { + const kind = itemKind(item); + const busy = busyId === item.id; + const nextAction = getNextInventoryAction(item); + + return ( + + {selectable && ( + + onToggleSelect?.(item.id)} + /> + + )} + + {item.bookingId ? ( + + + {item.bookingId.slice(0, 8)}... + + + ) : ( + + - + + )} + + {item.warehouse?.facility?.name ?? '-'} + {item.warehouse?.code ?? '-'} + {item.yard?.code ?? '-'} + {item.zone?.code ?? '-'} + + + {kind.label} + + + {formatNumber(item.quantity)} + {formatNumber(item.weight)} + + + + + {formatDate(item.arrivedAt)} + + + + {onView && ( + + onView(item)}> + + + + )} + {nextAction && ( + + )} + {item.status === 'READY_FOR_PICKUP' && ( + <> + + + + )} + {item.status !== 'DISPATCHED' && ( + + onMove(item)}> + + + + )} + {onInspect && ( + + onInspect(item)}> + + + + )} + {onFeePreview && ( + + onFeePreview(item)}> + + + + )} + {onReleaseDocument && item.releaseDate && ( + + onReleaseDocument(item)} + > + + + + )} + {onLastMile && item.booking?.lastMileDeliveryAddress && ( + + onLastMile(item)}> + + + + )} + + onHistory(item)}> + + + + + + + ); + })} + +
+
); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index 7f8dea0e5..7028a63a4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -1,10 +1,20 @@ import { useMemo } from 'react'; +import type { ReactNode } from 'react'; import { ActionIcon, Group, Text } from '@mantine/core'; -import { Eye, Pencil } from 'lucide-react'; +import { + Building2, + Eye, + MapPin, + Package, + Pencil, + Scale, + Warehouse as WarehouseIcon, +} from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { useQuery } from '@tanstack/react-query'; +import { bookingTable } from '@/components/bookings/booking-ui.styles'; import { api } from '@/services/api'; import type { Warehouse } from '@/types/warehouse'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; @@ -16,6 +26,43 @@ interface WarehouseTableProps { onEdit: (warehouse: Warehouse) => void; } +const HEADER = bookingTable.headerCell; + +function CapacityCell({ + current, + capacity, + icon, +}: { + current?: number | null; + capacity?: number | null; + icon: ReactNode; +}) { + const numericCurrent = Number(current) || 0; + const numericCapacity = Number(capacity) || 0; + const hasCapacity = numericCapacity > 0; + const ratio = hasCapacity ? Math.min(100, Math.max(0, (numericCurrent / numericCapacity) * 100)) : 0; + const isOverCapacity = hasCapacity && numericCurrent > numericCapacity; + + return ( +
+
+ + {icon} + + {formatCapacity(numericCurrent, capacity)} +
+ {hasCapacity ? ( +
+
+
+ ) : null} +
+ ); +} + export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) { const { data: stations } = useQuery( api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), @@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro const columns: ColumnDef[] = [ { id: 'code', - header: 'Code', + header: () => Warehouse, cell: ({ row }) => ( - onView(row.original)} - > - {row.original.code} - +
+
+ +
+
+ +

+ {row.original.name} +

+
+
), }, - { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, { id: 'facility', - header: 'Facility', + header: () => Facility, cell: ({ row }) => { const name = row.original.stationId ? stationNameById.get(row.original.stationId) : undefined; return name ? ( - - {name} - +
+ + {name} +
) : ( - — + - ); }, }, { id: 'type', - header: 'Type', - cell: ({ row }) => , + header: () => Type, + cell: ({ row }) => ( +
+ +
+ ), }, { id: 'location', - header: 'Location', - cell: ({ row }) => row.original.locationName ?? '—', + header: () => Location, + cell: ({ row }) => ( +
+ + {row.original.locationName ?? '-'} +
+ ), }, { id: 'weight', - header: 'Weight (cur / cap)', - cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight), + header: () => Weight, + cell: ({ row }) => ( + } + /> + ), }, { id: 'containers', - header: 'Containers (cur / cap)', - cell: ({ row }) => - formatCapacity(row.original.currentContainers, row.original.capacityContainers), + header: () => Containers, + cell: ({ row }) => ( + } + /> + ), }, { id: 'status', - header: 'Status', - cell: ({ row }) => , + header: () => Status, + cell: ({ row }) => ( +
+ +
+ ), }, { id: 'actions', @@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro status="success" onRowClick={(warehouse) => onView(warehouse)} emptyMessage="No warehouses found." - containerClassName="border-0 shadow-none" + containerClassName="border-0 bg-transparent shadow-none" /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index aebbd4de4..8f34de7e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -57,6 +57,8 @@ const inventoryStatusColor: Record = { RECEIVED: "yellow", STORED: "blue", RESERVED: "grape", + ARRIVED_AT_WAREHOUSE: "orange", + UNDER_INSPECTION: "yellow", READY_FOR_LOADING: "cyan", LOADED: "teal", READY_FOR_PICKUP: "teal", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index c0111fbef..570f1d090 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal'; export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable'; export { ActivityTimeline } from './ActivityTimeline'; export { InventoryHistoryModal } from './InventoryHistoryModal'; +export { InventoryDetailModal } from './InventoryDetailModal'; +export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal'; export { InventoryWorkbench } from './InventoryWorkbench'; export { BookingSelect } from './BookingSelect'; export { WagonSelect } from './WagonSelect'; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts new file mode 100644 index 000000000..7a467b9db --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts @@ -0,0 +1,24 @@ +export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window | null) { + const url = URL.createObjectURL(blob); + + if (targetWindow && !targetWindow.closed) { + targetWindow.location.href = url; + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return true; + } + + const opened = window.open(url, '_blank'); + if (opened) { + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return true; + } + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + return false; +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fc3897fa6..ede28be37 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -276,38 +276,43 @@ export const URL_CONSTANTS = { }, WAREHOUSE_YARDS: { + BASE: '/warehouse-yards', BY_ID: (id: string) => `/warehouse-yards/${id}`, ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`, }, WAREHOUSE_ZONES: { + BASE: '/warehouse-zones', BY_ID: (id: string) => `/warehouse-zones/${id}`, }, WAREHOUSE_INVENTORY: { BASE: '/warehouse-inventory', RECEIVE: '/warehouse-inventory/receive', + DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary', + READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading', + INQUIRY: '/warehouse-inventory/inquiry', + STORE: (id: string) => `/warehouse-inventory/${id}/store`, + INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`, + MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`, + LOAD: (id: string) => `/warehouse-inventory/${id}/load`, + DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`, + MOVE: (id: string) => `/warehouse-inventory/${id}/move`, RESERVE: '/warehouse-inventory/reserve', ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue', AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived', AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready', UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`, INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`, - READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading', - INQUIRY: '/warehouse-inventory/inquiry', LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons', BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`, - MOVE: (id: string) => `/warehouse-inventory/${id}/move`, MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`, ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`, LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`, - STORE: (id: string) => `/warehouse-inventory/${id}/store`, - MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`, - LOAD: (id: string) => `/warehouse-inventory/${id}/load`, - DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`, // Import branch MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`, RELEASE: (id: string) => `/warehouse-inventory/${id}/release`, + RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`, // Receive (Import/Export bulk) ELIGIBLE_BOOKINGS: (direction?: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/health/HealthCheck.tsx b/apps/edr-freight-web/backoffice/src/features/health/HealthCheck.tsx new file mode 100644 index 000000000..96d51298b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/health/HealthCheck.tsx @@ -0,0 +1,11 @@ +export const HealthCheck = () => { + const url1 = import.meta.env.VITE_API_URL?? "undefined"; + const url2 = import.meta.env.VITE_BASE_API_URL?? "undefined"; + const url3 = import.meta.env.VITE_USER_MANAGEMENT_BASE?? "undefined"; + + return
+

-----------{url1}

+

-----------{url2}

+

-----------{url3}

+
+} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts new file mode 100644 index 000000000..2bd7b552b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -0,0 +1,538 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { warehouseService } from '@/services/warehouse.service'; +import type { + InspectionReportPayload, + SaveAllocationRulePayload, + SaveFeeRulePayload, + WarehouseInvoiceFilter, + PayInvoicePayload, + InventoryFilter, + InventoryInquiryFilter, + LoadInventoryPayload, + MoveInventoryPayload, + ReceiveInventoryPayload, + ReleaseOrderPayload, + DeliverInventoryPayload, + BulkReceivePayload, + BulkInspectPayload, + ReserveInventoryPayload, + SaveWarehousePayload, + SaveYardPayload, + SaveZonePayload, + WarehouseFilter, +} from '@/types/warehouse'; + +export const warehouseKeys = { + all: ['warehouses'] as const, + list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const, + facilities: () => ['warehouses', 'facilities'] as const, + detail: (id: string) => ['warehouses', 'detail', id] as const, + yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const, + allYards: () => ['warehouse-yards', 'all'] as const, + zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const, + allZones: () => ['warehouse-zones', 'all'] as const, + inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const, + dashboardSummary: (filter?: InventoryFilter) => ['warehouse-dashboard', 'summary', filter ?? {}] as const, + inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const, +}; + +// ── Warehouses ───────────────────────────────────────────────────────────── + +export function useWarehouses(filter?: WarehouseFilter) { + return useQuery({ + queryKey: warehouseKeys.list(filter), + queryFn: () => warehouseService.list(filter).then((r) => r.data), + }); +} + +export function useWarehouse(id?: string) { + return useQuery({ + queryKey: warehouseKeys.detail(id ?? ''), + queryFn: () => warehouseService.getById(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useWarehouseFacilities() { + return useQuery({ + queryKey: warehouseKeys.facilities(), + queryFn: () => warehouseService.listFacilities().then((r) => r.data), + }); +} + +export function useCreateWarehouse() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), + }); +} + +export function useUpdateWarehouse() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + warehouseService.update(id, payload), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) }); + }, + }); +} + +// ── Yards ──────────────────────────────────────────────────────────────── + +export function useWarehouseYards(warehouseId?: string) { + return useQuery({ + queryKey: warehouseKeys.yards(warehouseId ?? ''), + queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data), + enabled: Boolean(warehouseId), + }); +} + +export function useAllWarehouseYards() { + return useQuery({ + queryKey: warehouseKeys.allYards(), + queryFn: () => warehouseService.listAllYards().then((r) => r.data), + }); +} + +export function useCreateYard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) => + warehouseService.createYard(warehouseId, payload), + onSuccess: (_, { warehouseId }) => { + qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) }); + qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) }); + }, + }); +} + +export function useUpdateYard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + warehouseService.updateYard(id, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), + }); +} + +// ── Zones ────────────────────────────────────────────────────────────────── + +export function useWarehouseZones(yardId?: string) { + return useQuery({ + queryKey: warehouseKeys.zones(yardId ?? ''), + queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data), + enabled: Boolean(yardId), + }); +} + +export function useAllWarehouseZones() { + return useQuery({ + queryKey: warehouseKeys.allZones(), + queryFn: () => warehouseService.listAllZones().then((r) => r.data), + }); +} + +export function useCreateZone() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) => + warehouseService.createZone(yardId, payload), + onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }), + }); +} + +export function useUpdateZone() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + warehouseService.updateZone(id, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }), + }); +} + +// ── Inventory ────────────────────────────────────────────────────────────── + +export function useWarehouseInventory(filter?: InventoryFilter) { + return useQuery({ + queryKey: warehouseKeys.inventory(filter), + queryFn: () => warehouseService.listInventory(filter).then((r) => r.data), + }); +} + +export function useWarehouseDashboardSummary(filter?: InventoryFilter) { + return useQuery({ + queryKey: warehouseKeys.dashboardSummary(filter), + queryFn: () => warehouseService.getDashboardSummary(filter).then((r) => r.data), + }); +} + +export function useReceiveInventory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] }); + }, + }); +} + +function useInventoryMutation(fn: (args: TArgs) => Promise) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + }, + }); +} + +export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id)); +export const useReserveInventory = () => + useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload)); +export const useMarkReadyForLoading = () => + useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id)); +export const useLoadInventory = () => + useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) => + warehouseService.load(args.id, args.payload), + ); +export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id)); +export const useMoveInventory = () => + useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) => + warehouseService.move(args.id, args.payload), + ); + +// ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────── +export const useMarkReadyForPickup = () => + useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id)); +export const useReleaseInventory = () => + useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) => + warehouseService.release(args.id, args.payload), + ); +export const useDeliverInventory = () => + useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) => + warehouseService.deliver(args.id, args.payload), + ); + +// ── Receive (Import/Export bulk) ─────────────────────────────────────────── +/** + * All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call. + * Both Receive tabs share this single query (same key) — only one HTTP request fires — + * then filter client-side by direction. + */ +export function useEligibleBookings(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'eligible-bookings'], + queryFn: () => warehouseService.eligibleBookings().then((r) => r.data), + enabled, + }); +} +export const useBulkReceive = () => + useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); +export const useLoadPassedExport = () => + useInventoryMutation(() => warehouseService.loadPassedExport()); +export const useBulkMarkInspected = () => + useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); + +export function useReadyToLoadExport(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'ready-to-load-export'], + queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data), + enabled, + }); +} + +export function useLoadedExport(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'loaded-export'], + queryFn: () => warehouseService.loadedExport().then((r) => r.data), + enabled, + }); +} + +export const useBulkDispatchExport = () => + useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds)); + +/** Arrived IMPORT trains (route-derived). Read-only. */ +export function useImportArriveQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-arrive-queue'], + queryFn: () => warehouseService.importArriveQueue().then((r) => r.data), + enabled, + }); +} + +/** Assigned bookings/items for an arrived import train. Read-only. */ +export function useImportTrainItems(scheduleId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-train-items', scheduleId], + queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data), + enabled: Boolean(scheduleId), + }); +} + +/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */ +export const useAutoUnloadArrivedBookings = () => + useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); + +/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ +export function useImportUnloadedQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-unloaded-queue'], + queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data), + enabled, + }); +} + +/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */ +export function useImportPickupReadyQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'], + queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data), + enabled, + }); +} + +// ── Loading (Batch 3) ──────────────────────────────────────────────────────── + +export function useLoadableWagons(enabled = true) { + return useQuery({ + queryKey: ['warehouse', 'loadable-wagons'], + queryFn: () => warehouseService.loadableWagons().then((r) => r.data), + enabled, + }); +} + +export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) { + return useQuery({ + queryKey: ['warehouse-loadings', params ?? {}], + queryFn: () => warehouseService.loadings(params).then((r) => r.data), + }); +} + +export function useBookingSchedule(bookingId?: string) { + return useQuery({ + queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''], + queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data), + enabled: Boolean(bookingId), + }); +} + +export function useInventoryMovements(id?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', id, 'movements'], + queryFn: () => warehouseService.movements(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useInventoryActivity(id?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', id, 'activity'], + queryFn: () => warehouseService.activity(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useWarehouseDashboard() { + return useQuery({ + queryKey: ['warehouses', 'dashboard'], + queryFn: () => warehouseService.dashboard().then((r) => r.data), + }); +} + +export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) { + return useQuery({ + queryKey: warehouseKeys.inquiry(filter), + queryFn: () => warehouseService.inquiry(filter).then((r) => r.data), + enabled, + }); +} + +// ── Batch 4.5: Arrival / Unload / Inspection ──────────────────────────────── + +export function useArrivalQueue() { + return useQuery({ + queryKey: ['warehouse-inventory', 'arrival-queue'], + queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), + }); +} + +function useArrivalInvalidation() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + }; +} + +export function useAutoUnloadArrived() { + const onSuccess = useArrivalInvalidation(); + return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess }); +} + +export function useAutoLoadReady() { + const onSuccess = useArrivalInvalidation(); + return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess }); +} + +export function useUnloadBooking() { + const onSuccess = useArrivalInvalidation(); + return useMutation({ + mutationFn: (args: { bookingId: string; payload?: Record }) => + warehouseService.unloadBooking(args.bookingId, args.payload), + onSuccess, + }); +} + +export function useInspectionReports(inventoryId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'], + queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data), + enabled: Boolean(inventoryId), + }); +} + +export function useCreateInspectionReport() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) => + warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data), + onSuccess: (_, { inventoryId }) => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] }); + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + }, + }); +} + +export function useUploadInspectionAttachments() { + return useMutation({ + mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) => + warehouseService.uploadInspectionAttachments(reportId, files), + }); +} + +// ── Batch 5: Allocation + Fee rules / preview ─────────────────────────────── + +export function useAllocationRules() { + return useQuery({ + queryKey: ['warehouse-allocation-rules'], + queryFn: () => warehouseService.listAllocationRules().then((r) => r.data), + }); +} + +export function useFeeRules() { + return useQuery({ + queryKey: ['warehouse-fee-rules'], + queryFn: () => warehouseService.listFeeRules().then((r) => r.data), + }); +} + +function useRuleMutation(fn: (args: TArgs) => Promise, keys: string[]) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })), + }); +} + +export const useCreateAllocationRule = () => + useRuleMutation( + (payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload), + ['warehouse-allocation-rules'], + ); +export const useUpdateAllocationRule = () => + useRuleMutation( + (args: { id: string; payload: Partial }) => + warehouseService.updateAllocationRule(args.id, args.payload), + ['warehouse-allocation-rules'], + ); +export const useDeleteAllocationRule = () => + useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']); + +export const useCreateFeeRule = () => + useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']); +export const useUpdateFeeRule = () => + useRuleMutation( + (args: { id: string; payload: Partial }) => + warehouseService.updateFeeRule(args.id, args.payload), + ['warehouse-fee-rules'], + ); +export const useDeleteFeeRule = () => + useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); + +export function useFeePreview(inventoryId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'], + queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data), + enabled: Boolean(inventoryId), + }); +} + +// ── Batch 6: Warehouse fee invoices ───────────────────────────────────────── + +export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) { + return useQuery({ + queryKey: ['warehouse-fee-invoices', filter ?? {}], + queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data), + }); +} + +export function useWarehouseInvoice(id?: string) { + return useQuery({ + queryKey: ['warehouse-fee-invoices', 'detail', id], + queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +export function useInvoicesForInventory(inventoryId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'], + queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data), + enabled: Boolean(inventoryId), + }); +} + +function useInvoiceInvalidation() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] }); + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + }; +} + +export function useGenerateInvoice() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ + mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) => + warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data), + onSuccess, + }); +} + +export function useCancelInvoice() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess }); +} + +export function usePayInvoice() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) => + warehouseService.payInvoice(id, payload), + onSuccess, + }); +} + +export function useGateClearance() { + const onSuccess = useInvoiceInvalidation(); + return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index ecf08bdfc..d61a47139 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -103,9 +103,8 @@ export default function BookingRequestsPage() { page: 1, pageSize: 100, statuses: "PAID", - schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE", assignedToSchedule: "false", - sortBy: "isGovernment", + sortBy: "createdAt", sortOrder: "DESC", tab: activeTab, }; 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/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 5be9f1e56..f45a35f34 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -360,7 +360,7 @@ const FleetResourcePage = () => { {config.subtitle}
- )} - @@ -718,6 +734,7 @@ const FirstMilePage = () => { key={option.value} size="xs" variant={active ? "filled" : "default"} + styles={{ label: { fontWeight: 500 } }} onClick={() => { setStatusFilter(option.value); setPagination((p) => ({ ...p, pageIndex: 0 })); @@ -865,33 +882,54 @@ const FirstMilePage = () => { No paid bookings found. ) : ( filteredPaidBookings.map((b) => ( - { setSelectedBooking(b); setAcceptStep(2); }} + style={{ + borderRadius: "var(--mantine-radius-md)", + border: "1px solid var(--mantine-color-gray-3)", + padding: "10px 12px", + backgroundColor: "var(--mantine-color-white)", + transition: "background-color 120ms ease, border-color 120ms ease", + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.backgroundColor = + "var(--mantine-color-blue-0)"; + (e.currentTarget as HTMLButtonElement).style.borderColor = + "var(--mantine-color-blue-4)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.backgroundColor = + "var(--mantine-color-white)"; + (e.currentTarget as HTMLButtonElement).style.borderColor = + "var(--mantine-color-gray-3)"; + }} > - - - {b.reference} - {b.company?.name ?? b.company?.companyName ?? "—"} - - - - {b.originYard?.name ?? "—"} → {b.destinationYard?.name ?? "—"} + + + {b.reference} + + {b.company?.name ?? b.company?.companyName ?? "—"} + + + + + {b.originYard?.label ?? "—"} → {b.destinationYard?.label ?? "—"} + + + {formatPrice(b.totalAmount)} - {formatPrice(b.totalAmount)} {b.scheduledDate ? b.scheduledDate.slice(0, 10) : "—"} + - + )) )} @@ -912,8 +950,8 @@ const FirstMilePage = () => { - - + + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index a5ad12c5b..f7807f90a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -71,7 +71,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ const vehicleLabel = (record: LastMileRecord) => { if (!record.vehicle) return null; const v = record.vehicle; - return `${v.manufacturer} ${v.model} (${v.plateNumber})`; + const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; + if (v.code) parts.unshift(v.code); + const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / "); + if (plates) parts.push(plates); + return parts.join(" · "); }; const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); @@ -313,10 +317,13 @@ const LastMilePage = () => { const vehicleOptions = useMemo( () => - (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({ - value: v.id, - label: `${v.manufacturer} ${v.model} (${v.plateNumber})`, - })), + (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => { + const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; + if (v.code) parts.unshift(v.code); + const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / "); + if (plates) parts.push(plates); + return { value: v.id, label: parts.join(" · ") }; + }), [vehiclesData], ); @@ -622,11 +629,11 @@ const LastMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -639,6 +646,7 @@ const LastMilePage = () => { key={option.value} size="xs" variant={active ? "filled" : "default"} + styles={{ label: { fontWeight: 500 } }} onClick={() => { setStatusFilter(option.value); setPagination((p) => ({ ...p, pageIndex: 0 })); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 317520f48..cf0a75fa8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -1,197 +1,271 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Fragment, useState } from 'react'; import { Badge, Button, Card, + Container, Group, + Loader, + Stack, + Table, Text, } from '@mantine/core'; -import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react'; -import { PageContainer, PageHeader } from '@/components/page'; +import { PageHeader } from '@/components/page'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { - InspectionReportModal, VisualEmptyState, + WarehouseHero, formatDate, + formatNumber, } from '@/components/warehouses'; -import { useMutation, useQuery } from '@tanstack/react-query'; - -import { api } from '@/services/api'; +import { + useAutoUnloadArrivedBookings, + useImportArriveQueue, + useImportTrainItems, +} from '@/hooks/useWarehouses'; import { useToast } from '@/hooks/use-toast'; -import type { ArrivalQueueItem } from '@/types/warehouse'; +import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse'; -function inspectionBadge(status: string | null) { - if (!status) return Not inspected; - const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange'; - return {status.replace(/_/g, ' ')}; -} +const getErrorMessage = (error: unknown) => { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { data?: { message?: unknown } } }).response; + const message = response?.data?.message; + if (Array.isArray(message)) return message.join(', '); + if (typeof message === 'string') return message; + } + return error instanceof Error ? error.message : undefined; +}; -/** Batch 4.5 — arrived bookings awaiting unload / inspection. */ -export default function ArrivalQueuePage() { - const navigate = useNavigate(); - const { toast } = useToast(); - const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions()); - const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions()); - const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions()); - const [inspectInventoryId, setInspectInventoryId] = useState(null); +function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { + const { data: items = [], isLoading } = useImportTrainItems(scheduleId); - const items = data ?? []; + if (isLoading) { + return ( + + + + ); + } - const handleAutoUnload = async () => { - try { - const r = await autoUnload.mutateAsync(); - toast({ - title: 'Auto-unload complete', - description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`, - }); - } catch { - toast({ variant: 'destructive', title: 'Auto-unload failed' }); - } - }; - - const handleUnloadOne = async (item: ArrivalQueueItem) => { - try { - await unloadOne.mutateAsync({ bookingId: item.bookingId }); - toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` }); - } catch { - toast({ variant: 'destructive', title: 'Unload failed' }); - } - }; - - const columns: ColumnDef[] = [ - { - id: 'booking', - header: 'Booking', - cell: ({ row }) => ( - - {row.original.bookingReference} - - ), - }, - { id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customer ?? '—' }, - { - id: 'cargo', - header: 'Cargo / Container', - cell: ({ row }) => row.original.container ?? row.original.cargo ?? '—', - }, - { - id: 'arrival', - header: 'Arrival', - cell: ({ row }) => {formatDate(row.original.arrivalDate)}, - }, - { id: 'facility', header: 'Facility', cell: ({ row }) => row.original.facility ?? '—' }, - { id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse ?? '—' }, - { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard ?? '—' }, - { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone ?? '—' }, - { - id: 'status', - header: 'Status', - cell: ({ row }) => - row.original.unloaded ? ( - - {row.original.currentStatus ?? 'RECEIVED'} - - ) : ( - - Not unloaded - - ), - }, - { - id: 'inspection', - header: 'Inspection', - cell: ({ row }) => inspectionBadge(row.original.inspectionStatus), - }, - { - id: 'actions', - header: '', - cell: ({ row }) => { - const item = row.original; - return ( - e.stopPropagation()}> - {!item.unloaded && ( - - )} - {item.inventoryId && ( - - )} - {item.inventoryId && ( - - )} - - ); - }, - }, - ]; + if (items.length === 0) { + return ( + + No assigned bookings found for this train. + + ); + } return ( - - } - loading={autoUnload.isPending} - onClick={handleAutoUnload} - > - Auto Unload Arrived Bookings - - } - /> - - - - {items.length} arrived booking(s) - - - {!isLoading && items.length === 0 ? ( - - ) : ( - - )} - - - setInspectInventoryId(null)} - inventoryId={inspectInventoryId} - /> - + + + + Booking + Customer + Container + Cargo + Weight + Arrival + Status + Pickup + + + + {items.map((item: ImportTrainItem) => ( + + + + {item.bookingReference ?? item.bookingId.slice(0, 8)} + + + {item.customerName ?? '-'} + {item.containerNumber ?? '-'} + {item.cargoType ?? '-'} + {formatNumber(item.weight)} + {formatDate(item.arrivalTime)} + + + {item.currentStatus ?? 'PENDING'} + + + {item.pickupOption.replace(/_/g, ' ')} + + ))} + +
+ ); +} + +/** Arrived import trains awaiting unload into warehouse inventory. */ +export default function ArrivalQueuePage() { + const { toast } = useToast(); + const { data: trains = [], isLoading } = useImportArriveQueue(); + const autoUnload = useAutoUnloadArrivedBookings(); + const [openScheduleId, setOpenScheduleId] = useState(null); + const [busyScheduleId, setBusyScheduleId] = useState(null); + + const unloadTrain = async (train: ImportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + const res = (await autoUnload.mutateAsync(train.scheduleId)) as { + data: AutoUnloadArrivedResult; + }; + const result = res.data; + const details = [ + result.skippedCount ? `${result.skippedCount} skipped` : '', + result.failedCount ? `${result.failedCount} failed` : '', + ] + .filter(Boolean) + .join(', '); + + toast({ + title: `${result.unloadedCount} booking(s) unloaded`, + description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Auto unload failed', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + + return ( + + + + + + + + + + + {trains.length} arrived import train(s) + + Open a train to review assigned bookings, then auto unload it. + + + + {isLoading ? ( + + + + ) : trains.length === 0 ? ( + + ) : ( + + + + + Train + Route + Origin + Destination + Arrival + Bookings + Containers + Cargoes + Status + Actions + + + + {trains.map((train: ImportTrain) => { + const isOpen = openScheduleId === train.scheduleId; + return ( + + + + + + {train.trainNumber ?? '-'} + + + {train.scheduleId.slice(0, 8)} + + + + {train.route ?? '-'} + {train.origin ?? '-'} + {train.destination ?? '-'} + + {formatDate(train.arrivalTime)} + + {train.totalBookings} + {train.totalContainers} + {train.totalCargoes} + + + {train.status} + + + + + + + + + + {isOpen && ( + + + + + + )} + + ); + })} + +
+
+ )} +
+
+
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx index 582a9cedd..d85cb815b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx @@ -3,35 +3,30 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@ import { Search } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; -import { useQuery } from '@tanstack/react-query'; - -import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses'; -import { api } from '@/services/api'; -import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse'; +import { + InventoryInquiryDetailModal, + VisualEmptyState, + WarehouseInquiryTable, + inventoryStatusOptions, +} from '@/components/warehouses'; +import { + useAllWarehouseYards, + useAllWarehouseZones, + useInventoryInquiry, + useWarehouses, +} from '@/hooks/useWarehouses'; +import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse'; export default function InventoryInquiryPage() { const [draft, setDraft] = useState({}); const [applied, setApplied] = useState({}); + const [viewResult, setViewResult] = useState(null); - const warehousesQuery = useQuery( - api.warehouses.list.queryOptions({ input: {} }), - ); - const yardsQuery = useQuery( - api.warehouses.listYards.queryOptions({ - input: { warehouseId: draft.warehouseId ?? '' }, - enabled: Boolean(draft.warehouseId), - }), - ); - const zonesQuery = useQuery( - api.warehouses.listZones.queryOptions({ - input: { yardId: draft.yardId ?? '' }, - enabled: Boolean(draft.yardId), - }), - ); + const warehousesQuery = useWarehouses(); + const yardsQuery = useAllWarehouseYards(); + const zonesQuery = useAllWarehouseZones(); - const { data, isFetching } = useQuery( - api.warehouses.inquiry.queryOptions({ input: { filter: applied } }), - ); + const { data, isFetching } = useInventoryInquiry(applied); const results = data ?? []; const warehouseOptions = useMemo( @@ -39,15 +34,39 @@ export default function InventoryInquiryPage() { [warehousesQuery.data], ); const yardOptions = useMemo( - () => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), - [yardsQuery.data], + () => + (yardsQuery.data ?? []) + .filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId) + .map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), + [draft.warehouseId, yardsQuery.data], ); const zoneOptions = useMemo( - () => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })), - [zonesQuery.data], + () => { + const visibleYardIds = new Set( + (yardsQuery.data ?? []) + .filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId) + .map((y) => y.id), + ); + return (zonesQuery.data ?? []) + .filter((z) => { + if (draft.yardId) return z.yardId === draft.yardId; + if (draft.warehouseId) return visibleYardIds.has(z.yardId); + return true; + }) + .map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })); + }, + [draft.warehouseId, draft.yardId, yardsQuery.data, zonesQuery.data], ); - const runSearch = () => setApplied(draft); + const normalizeDraft = (filter: InventoryInquiryFilter): InventoryInquiryFilter => ({ + ...filter, + bookingReference: filter.bookingReference?.trim() || undefined, + containerNumber: filter.containerNumber?.trim() || undefined, + cargoType: filter.cargoType?.trim() || undefined, + goodsName: filter.goodsName?.trim() || undefined, + }); + + const runSearch = () => setApplied(normalizeDraft(draft)); const reset = () => { setDraft({}); setApplied({}); @@ -60,101 +79,109 @@ export default function InventoryInquiryPage() { subtitle="Locate any cargo, container or goods inside the warehouse network." /> - - - - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }} - w={200} - /> - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }} - w={200} - /> - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }} - w={180} - /> - setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} - w={180} - /> - setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} - w={180} - /> - + + + + + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingReference: v || undefined })); }} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={200} + /> + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }} + w={200} + /> + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }} + w={180} + /> + setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} + w={180} + /> + setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} + w={180} + /> + - - - - - - + + + + + + - - {isFetching ? ( -
- -
- ) : results.length === 0 ? ( - - ) : ( - - )} -
+ + {isFetching ? ( +
+ +
+ ) : results.length === 0 ? ( + + ) : ( + + )} +
+ + setViewResult(null)} + result={viewResult} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx index 60dbb13c4..5df5018ec 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core'; +import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; import { ClipboardCheck, ClipboardList, @@ -16,10 +16,8 @@ import { } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; -import { useQuery } from '@tanstack/react-query'; - -import { WarehouseDashboardCharts } from '@/components/warehouses'; -import { api } from '@/services/api'; +import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses'; +import { useWarehouseDashboard } from '@/hooks/useWarehouses'; import type { WarehouseDashboard } from '@/types/warehouse'; interface Metric { @@ -31,8 +29,8 @@ interface Metric { theme: string; } -const ORANGE = '#f08c00'; -const GREEN = '#22c55e'; +const ORANGE = 'rgb(241, 147, 23)'; +const GREEN = '#084b21'; const METRICS: Metric[] = [ { key: 'totalWarehouses', label: 'Total Warehouses', icon: , to: '/dashboard/warehouses', theme: ORANGE }, @@ -51,7 +49,7 @@ const METRICS: Metric[] = [ export default function WarehouseDashboardPage() { const navigate = useNavigate(); - const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions()); + const { data, isError, isLoading } = useWarehouseDashboard(); return ( @@ -60,45 +58,58 @@ export default function WarehouseDashboardPage() { subtitle="Live overview of warehouse capacity and inventory lifecycle." /> - {isLoading ? ( -
- -
- ) : ( - <> - - {METRICS.map((metric) => ( - navigate(metric.to)} - className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" - > - -
- - {metric.label} - - - {data ? data[metric.key] : 0} - -
- - {metric.icon} - -
-
- ))} -
+ + - - - )} + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ Failed to load warehouse dashboard. +
+ ) : ( + <> + + {METRICS.map((metric) => ( + navigate(metric.to)} + className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" + > + +
+ + {metric.label} + + + {data ? data[metric.key] : 0} + +
+ + {metric.icon} + +
+
+ ))} +
+ + + + )} +
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx index 31509766d..be597bb77 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx @@ -5,16 +5,15 @@ import { Button, Card, Center, - Container, Group, Loader, - Stack, Select, + Stack, Tabs, Text, } from '@mantine/core'; import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react'; - +import { useQuery } from '@tanstack/react-query'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { KpiStrip, PageContainer, PageHeader } from '@/components/page'; @@ -27,8 +26,6 @@ import { formatCapacity, humanizeEnum, } from '@/components/warehouses'; -import { useQuery } from '@tanstack/react-query'; - import { api } from '@/services/api'; import type { WarehouseYard, WarehouseZone } from '@/types/warehouse'; @@ -51,7 +48,6 @@ export default function WarehouseDetailPage() { const [yardModalOpen, setYardModalOpen] = useState(false); const [editingYard, setEditingYard] = useState(null); - const [zoneModalOpen, setZoneModalOpen] = useState(false); const [editingZone, setEditingZone] = useState(null); const [selectedYardId, setSelectedYardId] = useState(null); @@ -169,14 +165,18 @@ export default function WarehouseDetailPage() { if (!warehouse) { return ( - - + + Warehouse not found - - + ); } @@ -189,9 +189,7 @@ export default function WarehouseDetailPage() { ]} backTo="/dashboard/warehouses" title={warehouse.name} - subtitle={`${warehouse.code}${ - warehouse.locationName ? ` · ${warehouse.locationName}` : '' - }`} + subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`} meta={ @@ -216,7 +214,6 @@ export default function WarehouseDetailPage() { - {/* OVERVIEW */} - {/* YARDS */} - - - - - Yards - - + + + + + Yards + + + void yardsQuery.refetch(), + } + : undefined + } + /> + + + + + + + + + - - - - {!selectedYardId ? ( - - Select a yard to view its zones. - - ) : ( - void zonesQuery.refetch(), - } - : undefined - } - /> - )} - - - - - {/* INVENTORY */} - - - - - + + + + + {id && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 2ae417db1..057dd0c21 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -10,9 +10,12 @@ import { ReceiveInventoryModal, inventoryStatusOptions, } from '@/components/warehouses'; -import { useQuery } from '@tanstack/react-query'; - -import { api } from '@/services/api'; +import { + useWarehouseInventory, + useWarehouseYards, + useWarehouseZones, + useWarehouses, +} from '@/hooks/useWarehouses'; import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; export default function WarehouseInventoryPage() { @@ -30,24 +33,10 @@ export default function WarehouseInventoryPage() { [filter, debouncedSearch], ); - const warehousesQuery = useQuery( - api.warehouses.list.queryOptions({ input: {} }), - ); - const yardsQuery = useQuery( - api.warehouses.listYards.queryOptions({ - input: { warehouseId: filter.warehouseId ?? '' }, - enabled: Boolean(filter.warehouseId), - }), - ); - const zonesQuery = useQuery( - api.warehouses.listZones.queryOptions({ - input: { yardId: filter.yardId ?? '' }, - enabled: Boolean(filter.yardId), - }), - ); - const inventoryQuery = useQuery( - api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }), - ); + const warehousesQuery = useWarehouses(); + const yardsQuery = useWarehouseYards(filter.warehouseId); + const zonesQuery = useWarehouseZones(filter.yardId); + const inventoryQuery = useWarehouseInventory(queryFilter); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), @@ -91,7 +80,12 @@ export default function WarehouseInventoryPage() { data={warehouseOptions} value={filter.warehouseId ?? null} onChange={(value) => - setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) + setFilter((f) => ({ + ...f, + warehouseId: value ?? undefined, + yardId: undefined, + zoneId: undefined, + })) } w={220} /> @@ -102,7 +96,9 @@ export default function WarehouseInventoryPage() { disabled={!filter.warehouseId} data={yardOptions} value={filter.yardId ?? null} - onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} + onChange={(value) => + setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined })) + } w={200} /> setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable /> - setForm((f) => ({ ...f, freightType: selectValue(v) }))} + clearable + /> + setForm((f) => ({ ...f, targetYardCode: selectValue(value) }))} + /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, storageType: value })); + }} + /> - - + +
@@ -178,9 +318,9 @@ function AllocationRules() { function FeeRules() { const { toast } = useToast(); - const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions()); - const create = useMutation(api.warehouses.createFeeRule.mutationOptions()); - const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions()); + const { data, isLoading } = useFeeRules(); + const create = useCreateFeeRule(); + const remove = useDeleteFeeRule(); const [open, setOpen] = useState(false); const [form, setForm] = useState({ name: '', @@ -199,6 +339,7 @@ function FeeRules() { toast({ variant: 'destructive', title: 'Name is required' }); return; } + await create.mutateAsync({ name: form.name.trim(), ruleType: form.ruleType, @@ -208,86 +349,158 @@ function FeeRules() { freeDays: form.freeDays, ratePerDay: form.ratePerDay, currency: form.currency || 'USD', - isActive: true, } as never); toast({ title: 'Fee rule created' }); setOpen(false); }; - const columns: ColumnDef<(typeof rules)[number]>[] = [ - { - id: 'type', - header: 'Type', - cell: ({ row }) => ( - - {row.original.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} - - ), - }, - { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, - { id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' }, - { id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' }, - { id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays }, - { - id: 'rate', - header: 'Rate / day', - cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`, - }, - { - id: 'active', - header: 'Active', - cell: ({ row }) => ( - - {row.original.isActive ? 'Yes' : 'No'} - - ), - }, - { - id: 'actions', - header: '', - cell: ({ row }) => ( - e.stopPropagation()}> - remove.mutate(row.original.id)} title="Delete"> - - - - ), - }, - ]; - return ( <> - {rules.length} rule(s) — most specific match applies - + + {rules.length} rule(s) - most specific match applies + + - + + {isLoading ? ( + + + + ) : ( + + + + + Type + Name + Freight + Trade + Free days + Rate / day + Active + Actions + + + + {rules.map((rule) => ( + + + + {rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} + + + {rule.name} + {rule.freightType ?? dash} + {rule.tradeDirection ?? dash} + {rule.freeDays} + + {Number(rule.ratePerDay).toLocaleString()} {rule.currency} + + + + {rule.isActive ? 'Yes' : 'No'} + + + + remove.mutate(rule.id)} + title="Delete" + > + + + + + ))} + +
+
+ )} setOpen(false)} title="New fee rule" centered size="lg"> - setForm((f) => ({ ...f, name: e.currentTarget.value }))} /> - ({ + value: type, + label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage', + }))} + value={form.ruleType} + onChange={(value) => + setForm((f) => ({ + ...f, + ruleType: selectValue(value, 'DEMURRAGE_FEE') as FeeRuleType, + })) + } + allowDeselect={false} + /> - setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable /> - setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))} + clearable + /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, cargoTypeCode: value })); + }} + /> - setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} /> - setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} /> - setForm((f) => ({ ...f, currency: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, freeDays: numberValue(value) }))} + /> + setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))} + /> + { - setServiceFilter(v); - resetPage(); - }} - clearable - radius="md" - comboboxProps={{ withinPortal: true }} - style={{ width: 200 }} - aria-label="Filter by service" - /> - )} - + - {query ? "No bookings match your search" : "No bookings here yet"} + {query + ? "No bookings match your search" + : "No bookings here yet"} {query @@ -821,13 +836,8 @@ export default function MyBookings() { to="/bookings/new" state={{ fresh: true }} size="sm" - color="edr-green" - radius="md" mt="md" - leftSection={} - > - Create first booking - + /> )} ) : ( @@ -835,7 +845,9 @@ export default function MyBookings() { columns={columns} data={rows} status={dataTableStatus} - onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)} + onRowClick={(row) => + navigate(`/bookings/${(row as Freight.IBooking).id}`) + } pagination={{ pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, @@ -861,7 +873,8 @@ export default function MyBookings() { bookingId={trackingBooking?.id ?? ""} bookingReference={trackingBooking?.reference ?? ""} originLabel={ - trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code + trackingBooking?.originYard?.label ?? + trackingBooking?.originYard?.code } destinationLabel={ trackingBooking?.destinationYard?.label ?? 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 89ee0edbd..cbc16d2ab 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 { useLocation, useNavigate } from "react-router-dom"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import { BookingFormInputValues, @@ -83,6 +83,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/.env.example b/apps/edr-passenger-api/.env.example index 482a0df2c..a4774af84 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -2,17 +2,46 @@ NODE_ENV=development PORT=4000 -# Database (Prisma) -DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger +# Database (Prisma) — owns the `passenger` schema in edr_database +DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger + +# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database. +# These mirror the connection vars read by @tria-plc/api-common's TypeORM DataSource. +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=edr_database +DATABASE_USER=edr +DATABASE_PASSWORD=edr_secret +DATABASE_SCHEMA=iam + +# RabbitMQ — the @tria-plc IAM/notification modules register RMQ clients (SMS/notifications). +# Connects lazily; a broker is only needed when those features actually send. Placeholder for dev. +RABBITMQ_URL=amqp://localhost:5672 + +# MinIO — the @tria-plc file/notification modules construct a MinIO client at boot (validates these). +# Placeholders for dev; only contacted when file upload/download features are actually used. +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=edr-dev # CORS FRONTEND_URL=http://localhost:5174 BACK_OFFICE_URL=http://localhost:5184 -# JWT +# JWT (legacy passenger auth — being replaced by IAM) JWT_SECRET=edr-platform-secret-change-in-production JWT_EXPIRES_IN=7d +# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with +# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.) +JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me +JWT_ACCESS_TOKEN_EXPIRES=1h +JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me +JWT_REFRESH_TOKEN_EXPIRES=7d + # SendGrid SENDGRID_API_KEY= SENDGRID_FROM_EMAIL=noreply@edr-platform.com diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 8ef7e669a..64637ee1a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -11,6 +11,8 @@ "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", "type-check": "tsc --noEmit", + "iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs", + "iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate deploy", "prisma:migrate:dev": "prisma migrate dev", @@ -27,26 +29,33 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.19", "@nestjs/event-emitter": "^2.0.4", - "@nestjs/jwt": "^10.2.0", "@nestjs/microservices": "^11.1.24", - "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.1", "@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.4.tgz", + "@types/bcrypt": "^6.0.0", + "amqp-connection-manager": "^5.0.0", + "amqplib": "^2.0.1", "axios": "^1.7.7", - "bcrypt": "^5.1.1", + "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "dotenv": "^17.4.2", "express": "^4.18.2", "jose": "^5.10.0", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", + "pg": "^8.21.0", "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", "tsconfig-paths": "^4.2.0", + "typeorm": "^0.3.30", "uuid": "^10.0.0" }, "devDependencies": { @@ -55,11 +64,10 @@ "@nestjs/cli": "^11.0.21", "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", - "@types/bcrypt": "^5.0.2", - "@types/express": "^5.0.6", + "@types/express": "^4.17.21", + "@types/luxon": "^3.7.1", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", - "@types/passport-jwt": "^4.0.1", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.0", diff --git a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql index 0f8484179..4ae48ea16 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql @@ -20,7 +20,7 @@ CREATE TYPE "IdDocumentType" AS ENUM ('NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENS CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD'); -- CreateEnum -CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED'); +CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED'); -- CreateEnum CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL'); @@ -76,7 +76,9 @@ CREATE TABLE "SeatClass" ( "coachTypeId" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, - "baseFareMinor" INTEGER NOT NULL, + "baseFareMinor" INTEGER NOT NULL DEFAULT 0, + "premiumMinor" INTEGER NOT NULL DEFAULT 0, + "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -94,6 +96,8 @@ CREATE TABLE "User" ( "role" "UserRole" NOT NULL DEFAULT 'PASSENGER', "nationality" TEXT, "nationalityCode" TEXT, + "gender" TEXT, + "dateOfBirth" TIMESTAMP(3), "passportNumber" TEXT, "nationalId" TEXT, "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0, @@ -155,10 +159,11 @@ CREATE TABLE "Station" ( "name" TEXT NOT NULL, "city" TEXT NOT NULL, "countryCode" TEXT, + "sequence" INTEGER NOT NULL DEFAULT 0, "isOperational" BOOLEAN NOT NULL DEFAULT true, "timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa', - "lat" DECIMAL(9,6) NOT NULL, - "lng" DECIMAL(9,6) NOT NULL, + "lat" DECIMAL(9,6), + "lng" DECIMAL(9,6), CONSTRAINT "Station_pkey" PRIMARY KEY ("id") ); @@ -234,6 +239,7 @@ CREATE TABLE "Coach" ( "number" TEXT NOT NULL, "arrangement" TEXT NOT NULL DEFAULT '2+2', "capacity" INTEGER NOT NULL DEFAULT 0, + "sequence" INTEGER NOT NULL DEFAULT 0, "status" TEXT NOT NULL DEFAULT 'ACTIVE', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, diff --git a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql new file mode 100644 index 000000000..9ccd3d52e --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql @@ -0,0 +1,14 @@ +-- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced) +ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT; + +-- Unique constraint: one IAM user maps to exactly one Passenger +ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); + +-- Index for fast lookup by iamUserId on every protected request +CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId"); + +-- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users) +ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT; + +-- Index for Fayda callback to resolve IAM user +CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql new file mode 100644 index 000000000..f2250e52b --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql @@ -0,0 +1,54 @@ +-- DropForeignKey +ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; + +-- AlterTable +ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "seatId" TEXT NOT NULL, + "seatIndex" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId"); + +-- AddForeignKey +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey' + AND conrelid = 'passenger."Passenger"'::regclass + ) THEN + ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey' + AND conrelid = 'passenger."TicketSeat"'::regclass + ) THEN + ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" + FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey' + AND conrelid = 'passenger."TicketSeat"'::regclass + ) THEN + ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" + FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql new file mode 100644 index 000000000..ec1cfd078 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql @@ -0,0 +1,13 @@ +-- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema) +ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; +ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; +ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; + +-- Rename columns (preserves all existing data) +ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; +ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; +ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; + +-- Rename indexes on FraudAlert to match new column name +DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; +CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql new file mode 100644 index 000000000..52914b220 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql @@ -0,0 +1,10 @@ +-- AuditLog: drop FK, rename column, update index +ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey"; +ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId"; +DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx"; +CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt"); + +-- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data) +ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey"; +ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId"; +DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql new file mode 100644 index 000000000..125074c12 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3); + +-- RenameIndex +ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql index 3b5beccb8..577312395 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql @@ -140,11 +140,7 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; -- AlterTable ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; --- AlterTable --- gender is created here on a clean migration history (no prior migration adds it); --- on an already-drifted DB where it exists as varchar, normalize it to TEXT. -ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT; -ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT; +-- gender column already TEXT from init migration -- CreateIndex CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql new file mode 100644 index 000000000..7e7d9bd58 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql @@ -0,0 +1,2 @@ +-- Empty placeholder migration +SELECT 1; diff --git a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql index 6c8da0d2c..1673a795b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql @@ -1,36 +1,9 @@ --- Add sequence column to Station table if it doesn't exist -ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence"); --- Add index on sequence for Station -CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence"); - --- Add sequence column to Coach table if it doesn't exist -ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0; - --- Add index on sequence for Coach -CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence"); - --- Add missing columns to SeatClass if they don't exist -ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0; -ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0; - --- Add missing columns to User if they don't exist -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255); -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3); -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255); -ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255); - --- Ensure Ticket has all required columns -ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3); -ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); - --- Add missing columns to Booking if they don't exist -ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY'; -ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255); -ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER; +CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence"); -- Ensure all indexes exist -CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode"); -CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId"); -CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId"); -CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status"); +CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode"); +CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId"); +CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId"); +CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql index d047e5a0c..9f70a96b1 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql @@ -1,164 +1,164 @@ -- Add CASCADE delete to all foreign key constraints that are missing it -- TrainSchedule relations -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "passenger"."Train"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "passenger"."Route"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey"; -ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; +ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey"; +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE; -- Coach relation -ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; -ALTER TABLE "passenger"."Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "passenger"."CoachType"("id") ON DELETE CASCADE; +ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; +ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE; -- CoachAssignment relations -ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; -ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey"; -ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "passenger"."Coach"("id") ON DELETE CASCADE; +ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey"; +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE; -- Booking relations -ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; -ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; +ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey"; -ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey"; +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- BookingSeat relations -ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; -ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey"; -ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; +ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey"; +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; -- PaymentIntent -ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; -ALTER TABLE "passenger"."PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; +ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- PaymentRefund -ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; -ALTER TABLE "passenger"."PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "passenger"."PaymentIntent"("id") ON DELETE CASCADE; +ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; +ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE; -- Ticket -ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; -ALTER TABLE "passenger"."Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- TicketSeat -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; -ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; +ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; -- WalletLedgerEntry -ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; -ALTER TABLE "passenger"."WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "passenger"."WalletAccount"("id") ON DELETE CASCADE; +ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; +ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE; -- Notification -ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; -ALTER TABLE "passenger"."Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; +ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; -- MenuItem -ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; -ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey"; -ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."MenuCategory"("id") ON DELETE CASCADE; +ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey"; +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE; -- FoodOrder -ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; -ALTER TABLE "passenger"."FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; +ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- FoodOrderItem -ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; -ALTER TABLE "passenger"."FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "passenger"."FoodOrder"("id") ON DELETE CASCADE; +ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; +ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE; -- FaqArticle -ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; -ALTER TABLE "passenger"."FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."FaqCategory"("id") ON DELETE CASCADE; +ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; +ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE; -- SupportMessage -ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; -ALTER TABLE "passenger"."SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "passenger"."SupportConversation"("id") ON DELETE CASCADE; +ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; +ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE; -- TripStopTime -ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; -ALTER TABLE "passenger"."TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; +ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- TripLiveStatus -ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; -ALTER TABLE "passenger"."TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; +ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- JourneySegment -ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; -ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE; +ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey"; -ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; +ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey"; +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; -- AgentBooking -ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; -ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; +ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; -ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey"; -ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey"; +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- AgentShift -ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; -ALTER TABLE "passenger"."AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; +ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; +ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; -- AgentCommission -ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; -ALTER TABLE "passenger"."AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; +ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; +ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; -- BookingModification -ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; -ALTER TABLE "passenger"."BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; +ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- BookingCancellation -ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; -ALTER TABLE "passenger"."BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; +ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- GateValidationLog -ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; -ALTER TABLE "passenger"."GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE; +ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; +ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE; -- BaggageBooking -ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; -ALTER TABLE "passenger"."BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; +ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; +ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; -- RouteFareRule -ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; -ALTER TABLE "passenger"."RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; +ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; +ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; -- SegmentFareRule -ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; -ALTER TABLE "passenger"."SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; +ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; -- StationCrowdSignal -ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; -ALTER TABLE "passenger"."StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; +ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; +ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE; -- SeatBlock -ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; -ALTER TABLE "passenger"."SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; +ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; +ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; -- SavedRoute -ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; -ALTER TABLE "passenger"."SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; +ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; +ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; -- LoyaltyLedgerEntry -ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; -ALTER TABLE "passenger"."LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE; +ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; +ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; -- LoyaltyReward -ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; -ALTER TABLE "passenger"."LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE; +ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; +ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; -- FareRule -ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; -ALTER TABLE "passenger"."FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; +ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; +ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql new file mode 100644 index 000000000..582db9567 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql @@ -0,0 +1,82 @@ +-- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.* +-- but ran when tables were still in public schema (before 20260626 moved them). +-- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run. + +-- ──────────────────────────────────────────────────────────── +-- 1. Passenger.iamUserId +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'Passenger_iamUserId_key' + AND conrelid = 'passenger."Passenger"'::regclass + ) THEN + ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId"); + +-- ──────────────────────────────────────────────────────────── +-- 2. FaydaVerificationSession.iamUserId +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; +CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId"); + +-- ──────────────────────────────────────────────────────────── +-- 3. UserPreferences: rename userId → iamUserId (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; + ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; + END IF; +END $$; + +-- ──────────────────────────────────────────────────────────── +-- 4. Device: rename userId → iamUserId (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; + ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; + END IF; +END $$; + +-- ──────────────────────────────────────────────────────────── +-- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; + ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; + DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; + CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); + END IF; +END $$; + +-- ──────────────────────────────────────────────────────────── +-- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId' + ) THEN + ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey"; + ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId"; + DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx"; + CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt"); + END IF; +END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql new file mode 100644 index 000000000..33f793aa3 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql @@ -0,0 +1,5 @@ +-- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat). +-- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL. + +ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; +ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql new file mode 100644 index 000000000..fb2e47592 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql @@ -0,0 +1,46 @@ +-- ──────────────────────────────────────────────────────────── +-- 1. Add iamUserId to Agent +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'Agent_iamUserId_key' + AND conrelid = 'passenger."Agent"'::regclass + ) THEN + ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId"); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId"); + +-- ──────────────────────────────────────────────────────────── +-- 2. Populate iamUserId for existing agent records +-- Match via User.email → iam.users.email (skip if iam schema absent) +-- ──────────────────────────────────────────────────────────── +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'iam' AND table_name = 'users' + ) THEN + UPDATE passenger."Agent" a + SET "iamUserId" = iu.id + FROM passenger."User" u + JOIN iam.users iu ON iu.email = u.email + WHERE a."userId" = u.id + AND a."iamUserId" IS NULL; + END IF; +END $$; + +-- ──────────────────────────────────────────────────────────── +-- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey"; +DROP INDEX IF EXISTS passenger."Agent_userId_key"; +ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId"; + +-- ──────────────────────────────────────────────────────────── +-- 4. Drop Passenger.userId FK (column stays as plain nullable string) +-- ──────────────────────────────────────────────────────────── +ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql new file mode 100644 index 000000000..a20893705 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql @@ -0,0 +1,290 @@ +-- DropForeignKey +ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey"; + +-- DropForeignKey +ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey"; + +-- DropForeignKey +ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey"; + +-- DropForeignKey +ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey"; + +-- DropForeignKey +ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey"; + +-- DropForeignKey +ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey"; + +-- DropForeignKey +ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey"; + +-- DropForeignKey +ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey"; + +-- DropForeignKey +ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey"; + +-- DropForeignKey +ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey"; + +-- DropForeignKey +ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey"; + +-- DropForeignKey +ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey"; + +-- DropForeignKey +ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey"; + +-- DropForeignKey +ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey"; + +-- DropForeignKey +ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey"; + +-- DropForeignKey +ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey"; + +-- DropForeignKey +ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey"; + +-- DropForeignKey +ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey"; + +-- DropForeignKey +ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey"; + +-- DropForeignKey +ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey"; + +-- DropForeignKey +ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey"; + +-- DropForeignKey +ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey"; + +-- DropForeignKey +ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey"; + +-- DropForeignKey +ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey"; + +-- DropForeignKey +ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey"; + +-- DropIndex +DROP INDEX IF EXISTS "Journey_bookingId_idx"; + +-- AlterTable +ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY'; + +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'passenger' AND table_name = 'Journey' AND column_name = 'bookingId' + ) THEN + ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" + FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql index e93fb8320..bc6e6c2a2 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql @@ -1,18 +1,18 @@ -- CreateEnum -CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); +CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); -- AlterTable: add return leg tracking columns to Booking -ALTER TABLE "passenger"."Booking" - ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', +ALTER TABLE "Booking" + ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', ADD COLUMN "outboundBoardedAt" TIMESTAMP(3), ADD COLUMN "returnBoardedAt" TIMESTAMP(3); -- Set NEITHER_USED for existing confirmed round-trip bookings -UPDATE "passenger"."Booking" +UPDATE "Booking" SET "returnLegStatus" = 'NEITHER_USED' WHERE "bookingType" = 'ROUND_TRIP' - AND "status" IN ('CONFIRMED', 'COMPLETED'); + AND "status" IN ('CONFIRMED', 'BOARDED'); -- AlterTable: add leg column to GateValidationLog -ALTER TABLE "passenger"."GateValidationLog" +ALTER TABLE "GateValidationLog" ADD COLUMN "leg" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql index f252642f6..b0a5bc0b4 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql @@ -1,7 +1,7 @@ -- Create passenger schema if it doesn't exist CREATE SCHEMA IF NOT EXISTS passenger; --- Move all enums from public to passenger schema +-- Move enums from public to passenger schema (only if they exist in public) DO $$ DECLARE e text; @@ -13,9 +13,10 @@ BEGIN LOOP EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); END LOOP; +EXCEPTION WHEN others THEN NULL; END $$; --- Move all tables from public to passenger schema +-- Move tables from public to passenger schema (only if they exist in public) DO $$ DECLARE t text; @@ -26,6 +27,7 @@ BEGIN LOOP EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); END LOOP; +EXCEPTION WHEN others THEN NULL; END $$; -- Add missing columns to Booking diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql new file mode 100644 index 000000000..12f4a0eb7 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql @@ -0,0 +1,21 @@ +-- Add bookingId to Journey for per-booking segment release +ALTER TABLE "passenger"."Journey" + ADD COLUMN IF NOT EXISTS "bookingId" TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId"); +CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId"); + +-- AddForeignKey (column created above; FK was misplaced in 20260623073543_config) +ALTER TABLE "passenger"."Journey" + DROP CONSTRAINT IF EXISTS "Journey_bookingId_fkey"; +ALTER TABLE "passenger"."Journey" + ADD CONSTRAINT "Journey_bookingId_fkey" + FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Ensure JourneySegment cascades on Journey delete +ALTER TABLE "passenger"."JourneySegment" + DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; + +ALTER TABLE "passenger"."JourneySegment" + ADD CONSTRAINT "JourneySegment_journeyId_fkey" + FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql new file mode 100644 index 000000000..2dff947d2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql @@ -0,0 +1,153 @@ +-- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema) +ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'Agent_iamUserId_key' + AND conrelid = 'passenger."Agent"'::regclass + ) THEN + ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId"); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId"); + +-- Drop old Agent.userId FK and column if they still exist +ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey"; +DROP INDEX IF EXISTS passenger."Agent_userId_key"; +ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId"; + +-- Drop old Passenger.userId FK (column stays as plain nullable string) +ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; + +-- TravelPackage +CREATE TABLE IF NOT EXISTS passenger."TravelPackage" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "outboundScheduleId" TEXT NOT NULL, + "returnScheduleId" TEXT NOT NULL, + "originStationId" TEXT NOT NULL, + "destinationStationId" TEXT NOT NULL, + "boardingTime" TIMESTAMP(3) NOT NULL, + "departureTime" TIMESTAMP(3) NOT NULL, + "arrivalTime" TIMESTAMP(3) NOT NULL, + "totalCapacity" INTEGER NOT NULL, + "bookedCount" INTEGER NOT NULL DEFAULT 0, + "includedServices" JSONB NOT NULL, + "coachConfiguration" TEXT, + "busTransferIncluded" BOOLEAN NOT NULL DEFAULT false, + "busTransferRoute" TEXT, + "validFrom" TIMESTAMP(3) NOT NULL, + "validUntil" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code"); +CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom"); + +-- PackagePriceTier +CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "seatType" TEXT NOT NULL, + "label" TEXT NOT NULL, + "priceMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "availableSeats" INTEGER NOT NULL DEFAULT 0, + "bookedSeats" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType"); + +-- PackageBooking +CREATE TABLE IF NOT EXISTS passenger."PackageBooking" ( + "id" TEXT NOT NULL, + "bookingRef" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "priceTierId" TEXT NOT NULL, + "passengerId" TEXT, + "contactEmail" TEXT, + "contactPhone" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', + "passengerCount" INTEGER NOT NULL DEFAULT 1, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "displayCurrency" TEXT, + "displayTotalMinor" INTEGER, + "promoCode" TEXT, + "source" TEXT NOT NULL DEFAULT 'WEB', + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef"); +CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status"); + +-- PackageBookingPassenger +CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "passengerName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3), + "idDocumentType" TEXT, + "idDocumentNumber" TEXT, + "passportNumber" TEXT, + "passportCountry" TEXT, + "seatLabel" TEXT, + CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id") +); + +-- PackagePaymentIntent +CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" ( + "id" TEXT NOT NULL, + "packageBookingId" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "method" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION', + "providerRef" TEXT, + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId"); + +-- Foreign keys +ALTER TABLE passenger."TravelPackage" + ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" + FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."TravelPackage" + ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" + FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackagePriceTier" + ADD CONSTRAINT "PackagePriceTier_packageId_fkey" + FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBooking" + ADD CONSTRAINT "PackageBooking_packageId_fkey" + FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBooking" + ADD CONSTRAINT "PackageBooking_priceTierId_fkey" + FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBooking" + ADD CONSTRAINT "PackageBooking_passengerId_fkey" + FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE passenger."PackageBookingPassenger" + ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" + FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE passenger."PackagePaymentIntent" + ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" + FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql new file mode 100644 index 000000000..545b6a5b4 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql @@ -0,0 +1,12 @@ +-- Create PackageStatus enum +DO $$ BEGIN + CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Drop default, cast column to enum, restore default +ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE passenger."TravelPackage" + ALTER COLUMN "status" TYPE passenger."PackageStatus" + USING "status"::passenger."PackageStatus"; +ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql new file mode 100644 index 000000000..04e2a7610 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "passenger"."ExcessBaggageCharge" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "excessWeightKg" INTEGER NOT NULL, + "feePerKgMinor" INTEGER NOT NULL, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "paymentToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "paidAt" TIMESTAMP(3), + "waivedBy" TEXT, + "waivedReason" TEXT, + "contactPhone" TEXT, + "contactEmail" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status"); + +-- AddForeignKey +ALTER TABLE "passenger"."ExcessBaggageCharge" + ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" + FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Seed default paymentToken using gen_random_uuid() for any rows that may exist +UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = ''; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 423a03ec9..b01ee7b0c 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -108,7 +108,7 @@ enum BookingStatus { PENDING_PAYMENT CONFIRMED CANCELLED - COMPLETED + BOARDED NO_SHOW REFUNDED @@ -260,15 +260,9 @@ model User { faydaVerifiedAt DateTime? faydaSub String? @unique - passenger Passenger? - agent Agent? - sessions Session[] - devices Device[] - preferences UserPreferences? - auditLogs AuditLog[] - fraudAlerts FraudAlert[] + sessions Session[] + passenger Passenger? - faydaVerificationSessions FaydaVerificationSession[] @@schema("passenger") } @@ -286,20 +280,23 @@ model Session { } model Passenger { - id String @id @default(uuid()) - userId String @unique + id String @id @default(uuid()) + userId String? @unique + iamUserId String? @unique defaultTravelerProfileId String? preferredLanguage String? + blockedUntil DateTime? createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id]) bookings Booking[] loyalty LoyaltyAccount? wallet WalletAccount? notifications Notification[] travelerProfiles TravelerProfile[] savedRoutes SavedRoute[] - + packageBookings PackageBooking[] @@index([userId]) + @@index([iamUserId]) @@schema("passenger") } @@ -325,8 +322,8 @@ model Station { sequence Int @default(0) isOperational Boolean @default(true) timezone String @default("Africa/Addis_Ababa") - lat Decimal @db.Decimal(9, 6) - lng Decimal @db.Decimal(9, 6) + lat Decimal? @db.Decimal(9, 6) + lng Decimal? @db.Decimal(9, 6) originSchedules TrainSchedule[] @relation("OriginTrips") destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] @@ -376,6 +373,8 @@ model TrainSchedule { liveStatus TripLiveStatus? menuItems MenuItem[] journeySegments JourneySegment[] + outboundPackages TravelPackage[] @relation("PackageOutbound") + returnPackages TravelPackage[] @relation("PackageReturn") @@index([departureAt, originStationId]) @@schema("passenger") @@ -548,6 +547,8 @@ model Booking { modifications BookingModification[] cancellation BookingCancellation? baggage BaggageBooking[] + excessBaggageCharges ExcessBaggageCharge[] + journey Journey? @@index([passengerId, status]) @@index([bookingType]) @@ -894,7 +895,7 @@ model SupportMessage { model UserPreferences { id String @id @default(uuid()) - userId String @unique + iamUserId String @unique pushEnabled Boolean @default(true) emailEnabled Boolean @default(true) smsEnabled Boolean @default(false) @@ -907,19 +908,19 @@ model UserPreferences { locale String @default("en") darkMode Boolean @default(false) language String @default("en") - user User @relation(fields: [userId], references: [id]) + @@schema("passenger") } model Device { id String @id @default(uuid()) - userId String + iamUserId String platform DevicePlatform name String pushToken String? trusted Boolean @default(false) lastSeenAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + @@schema("passenger") } @@ -939,10 +940,12 @@ model SavedRoute { model Journey { id String @id @default(uuid()) passengerId String + bookingId String? @unique status String totalMinor Int currency String @default("ETB") createdAt DateTime @default(now()) + booking Booking? @relation(fields: [bookingId], references: [id]) journeySegments JourneySegment[] @@schema("passenger") } @@ -1060,16 +1063,16 @@ model SegmentFareRule { model Agent { id String @id @default(uuid()) - userId String @unique + iamUserId String? @unique agentCode String @unique stationId String? commissionRate Int @default(5) active Boolean @default(true) createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) bookings AgentBooking[] shifts AgentShift[] commissions AgentCommission[] + @@index([iamUserId]) @@schema("passenger") } @@ -1187,20 +1190,43 @@ model BaggageBooking { @@schema("passenger") } -model AuditLog { - id String @id @default(uuid()) - userId String? - action String - entityType String - entityId String? - oldData Json? - newData Json? - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - user User? @relation(fields: [userId], references: [id]) +model ExcessBaggageCharge { + id String @id @default(uuid()) + bookingId String + agentId String + excessWeightKg Int + feePerKgMinor Int + totalMinor Int + currency String @default("ETB") + status String @default("PENDING") // PENDING | PAID | EXPIRED | WAIVED | CASH_COLLECTED + paymentToken String @unique @default(uuid()) + expiresAt DateTime + paidAt DateTime? + waivedBy String? + waivedReason String? + contactPhone String? + contactEmail String? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) - @@index([userId, createdAt]) + @@index([bookingId]) + @@index([paymentToken]) + @@index([status]) + @@schema("passenger") +} + +model AuditLog { + id String @id @default(uuid()) + iamUserId String? + action String + entityType String + entityId String? + oldData Json? + newData Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + @@index([iamUserId, createdAt]) @@index([entityType, entityId]) @@schema("passenger") } @@ -1256,16 +1282,14 @@ model FraudRule { model FraudAlert { id String @id @default(uuid()) - userId String + iamUserId String eventType String triggeredRules String[] context Json severity String @default("MEDIUM") acknowledged Boolean @default(false) createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, createdAt]) + @@index([iamUserId, createdAt]) @@index([acknowledged]) @@schema("passenger") } @@ -1324,7 +1348,7 @@ model FaydaVerificationSession { id String @id @default(uuid()) state String @unique codeVerifier String - purpose String @default("PURCHASE") + purpose String @default("VERIFY") // VERIFY | LOGIN platform String @default("WEB") // WEB | MOBILE — recorded for audit saveToAccount Boolean @default(false) status String @default("PENDING") @@ -1335,14 +1359,144 @@ model FaydaVerificationSession { expiresAt DateTime completedAt DateTime? - userId String? + iamUserId String? bookingId String? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) + @@index([iamUserId]) @@index([bookingId]) @@index([state]) @@index([expiresAt]) @@schema("passenger") } + +model SystemConfig { + id String @id @default(uuid()) + key String @unique + value String + updatedAt DateTime @updatedAt + + @@schema("passenger") +} + +enum PackageStatus { + DRAFT + ACTIVE + SOLD_OUT + EXPIRED + CANCELLED + + @@schema("passenger") +} + +model TravelPackage { + id String @id @default(uuid()) + code String @unique + name String + description String? + status PackageStatus @default(DRAFT) + outboundScheduleId String + returnScheduleId String + originStationId String + destinationStationId String + boardingTime DateTime + departureTime DateTime + arrivalTime DateTime + totalCapacity Int + bookedCount Int @default(0) + includedServices Json + coachConfiguration String? + busTransferIncluded Boolean @default(false) + busTransferRoute String? + validFrom DateTime + validUntil DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id]) + returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id]) + priceTiers PackagePriceTier[] + bookings PackageBooking[] + + @@index([status, validFrom]) + @@schema("passenger") +} + +model PackagePriceTier { + id String @id @default(uuid()) + packageId String + seatType String + label String + priceMinor Int + currency String @default("ETB") + availableSeats Int @default(0) + bookedSeats Int @default(0) + + package TravelPackage @relation(fields: [packageId], references: [id]) + bookings PackageBooking[] + + @@unique([packageId, seatType]) + @@schema("passenger") +} + +model PackageBooking { + id String @id @default(uuid()) + bookingRef String @unique + packageId String + priceTierId String + passengerId String? + contactEmail String? + contactPhone String? + status BookingStatus @default(PENDING_PAYMENT) + passengerCount Int @default(1) + totalMinor Int + currency String @default("ETB") + displayCurrency Currency? + displayTotalMinor Int? + promoCode String? + source String @default("WEB") + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + package TravelPackage @relation(fields: [packageId], references: [id]) + priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id]) + passenger Passenger? @relation(fields: [passengerId], references: [id]) + passengers PackageBookingPassenger[] + paymentIntent PackagePaymentIntent? + + @@index([packageId, status]) + @@schema("passenger") +} + +model PackageBookingPassenger { + id String @id @default(uuid()) + bookingId String + passengerName String + dateOfBirth DateTime? + idDocumentType IdDocumentType? + idDocumentNumber String? + passportNumber String? + passportCountry String? + seatLabel String? + + booking PackageBooking @relation(fields: [bookingId], references: [id]) + + @@schema("passenger") +} + +model PackagePaymentIntent { + id String @id @default(uuid()) + packageBookingId String @unique + amountMinor Int + currency String @default("ETB") + method PaymentMethodType + status PaymentIntentStatus @default(REQUIRES_ACTION) + providerRef String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + packageBooking PackageBooking @relation(fields: [packageBookingId], references: [id]) + + @@schema("passenger") +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 34e5e1dc8..9c5a22b0f 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -59,9 +59,9 @@ async function seedSystemUsers() { }); } await prisma.userPreferences.upsert({ - where: { userId: passenger.id }, + where: { iamUserId: passenger.id }, update: {}, - create: { userId: passenger.id, language: 'en' }, + create: { iamUserId: passenger.id, language: 'en' }, }); console.log(' ✅ Passenger: kelemu@email.com / password123'); @@ -79,9 +79,9 @@ async function seedSystemUsers() { }, }); await prisma.agent.upsert({ - where: { userId: agent.id }, + where: { agentCode: 'AG0001' }, update: {}, - create: { userId: agent.id, agentCode: 'AG0001', commissionRate: 5 }, + create: { agentCode: 'AG0001', commissionRate: 5 }, }); console.log(' ✅ Agent: agent@edr-platform.com / agent123'); @@ -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' }, ]; @@ -670,6 +670,70 @@ async function seedFraudRules() { console.log(` ✅ ${rules.length} fraud detection rules created`); } +async function seedKulubbiPackage() { + console.log('\n🚆 Seeding Kulubbi Gabriel 2025 package...'); + + const addisStation = await prisma.station.findFirst({ where: { code: 'SBT' } }); + const direDawaStation = await prisma.station.findFirst({ where: { code: 'DRE' } }); + if (!addisStation || !direDawaStation) { + console.log(' ⚠️ Stations not found, skipping Kulubbi package seed'); + return; + } + + // Use the first two schedules as outbound/return (or create dedicated ones) + const schedules = await prisma.trainSchedule.findMany({ take: 2, orderBy: { departureAt: 'asc' } }); + if (schedules.length < 2) { + console.log(' ⚠️ Not enough schedules found, skipping Kulubbi package seed'); + return; + } + const [outboundSchedule, returnSchedule] = schedules; + + await prisma.travelPackage.upsert({ + where: { code: 'KULUBBI-2025' }, + update: {}, + create: { + code: 'KULUBBI-2025', + name: 'Kulubbi Gabriel Pilgrimage Package', + description: 'Annual pilgrimage round-trip package to Kulubi Gabriel Church. Includes train travel, bus transfer, meals, and entertainment.', + outboundScheduleId: outboundSchedule.id, + returnScheduleId: returnSchedule.id, + originStationId: addisStation.id, + destinationStationId: direDawaStation.id, + boardingTime: new Date('2025-07-24T07:00:00+03:00'), + departureTime: new Date('2025-07-24T09:00:00+03:00'), + arrivalTime: new Date('2025-07-25T06:00:00+03:00'), + totalCapacity: 912, + coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC', + busTransferIncluded: true, + busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel', + validFrom: new Date('2025-07-01'), + validUntil: new Date('2025-07-24T09:00:00+03:00'), + status: 'ACTIVE', + includedServices: [ + 'Round-trip train travel (Addis Ababa ↔ Dire Dawa)', + 'Lunch served on board', + 'Refreshments and bottled water', + 'Round-trip bus transfer (Dire Dawa ↔ Kulubi Gabriel)', + 'Onboard first aid and medical support', + 'Entertainment (audio/video)', + 'Service briefing and pilgrimage guidance', + 'Pick-up and drop-off coordination', + ], + priceTiers: { + create: [ + { seatType: 'HSC', label: 'Regular Seat (HSC)', priceMinor: 1023200, availableSeats: 550 }, + { seatType: 'ECU', label: 'Economic Bed Upper (ECU)', priceMinor: 1295200, availableSeats: 80 }, + { seatType: 'ECM', label: 'Economic Bed Middle (ECM)', priceMinor: 1364000, availableSeats: 80 }, + { seatType: 'ECL', label: 'Economic Bed Lower (ECL)', priceMinor: 1430200, availableSeats: 80 }, + { seatType: 'VIU', label: 'VIP Bed Upper (VIU)', priceMinor: 1243500, availableSeats: 61 }, + { seatType: 'VIL', label: 'VIP Bed Lower (VIL)', priceMinor: 1643500, availableSeats: 61 }, + ], + }, + }, + }); + console.log(' ✅ Kulubbi Gabriel 2025 package created'); +} + // Run a seed step in isolation: if it throws (FK conflict, duplicate row, // missing record, etc.) log the error and keep going so the rest of the seed — // and the API startup that follows it — are never blocked by one bad step. @@ -691,7 +755,8 @@ async function main() { ['fare rules', seedFareRules], ['segment fares', seedSegmentFares], ['currency', seedCurrency], - ['notification templates', seedNotificationTemplates] + ['notification templates', seedNotificationTemplates], + ['kulubbi package', seedKulubbiPackage], ]; let failed = 0; diff --git a/apps/edr-passenger-api/scripts/run-iam-migrations.cjs b/apps/edr-passenger-api/scripts/run-iam-migrations.cjs new file mode 100644 index 000000000..1d7537e42 --- /dev/null +++ b/apps/edr-passenger-api/scripts/run-iam-migrations.cjs @@ -0,0 +1,47 @@ +/** + * Dev helper: run the @tria-plc/iamapi-common TypeORM migrations against the shared `iam` schema. + * + * The package ships its migration CLI assuming you run it from inside the package repo (it needs + * the package's devDeps). As a consumer we instead drive the shipped (compiled) migrations with the + * passenger app's own installed TypeORM. + * + * Reads the same DATABASE_* env vars as the app's IAM DataSource (see config/iam-database.config.ts). + * Run via: pnpm --filter @edr/passenger-api iam:migrate + * (the npm script loads .env with `node --env-file`). + * + * NOTE: in production the central IAM team owns/runs these migrations — this helper is for local dev. + */ +const path = require('path'); +const { DataSource } = require('typeorm'); + +const iamDist = path + .dirname(require.resolve('@tria-plc/iamapi-common')) + .replace(/\\/g, '/'); + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: Number(process.env.DATABASE_PORT || 5432), + database: process.env.DATABASE_NAME, + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + schema: process.env.DATABASE_SCHEMA || 'iam', + entities: [], // migrations are raw SQL — no entities needed to run them + migrations: [`${iamDist}/db/migrations/*.js`], + migrationsTableName: 'typeorm_migrations', +}); + +(async () => { + await ds.initialize(); + await ds.query('CREATE SCHEMA IF NOT EXISTS iam'); + // The IAM migrations rely on uuid_generate_v4() but never CREATE the extension themselves. + await ds.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + const applied = await ds.runMigrations({ transaction: 'each' }); + console.log(`[iam-migrations] applied ${applied.length} migration(s)`); + applied.slice(-5).forEach((m) => console.log(' +', m.name)); + await ds.destroy(); + console.log('[iam-migrations] DONE'); +})().catch((e) => { + console.error('[iam-migrations] FAIL:', e.message); + process.exit(1); +}); diff --git a/apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs b/apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs new file mode 100644 index 000000000..37451f871 --- /dev/null +++ b/apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs @@ -0,0 +1,74 @@ +/** + * Dev helper: create a dev IAM user + an ACTIVE session, and print a ready-to-use Bearer token. + * + * Why this exists: in prod the central IAM service issues tokens (via password login at + * /v1/auth/login). For local dev of the passenger API (a token *consumer*), this seeds a session + * directly and mints a matching token with the package's own `generateToken`, so you can call + * protected routes immediately (paste the token into Swagger's Authorize box or `curl -H`). + * + * Run: pnpm --filter @edr/passenger-api iam:seed-dev-user + * Reads DATABASE_* + JWT_ACCESS_TOKEN_SECRET/EXPIRES from .env (loaded via `node --env-file`). + */ +const crypto = require('crypto'); +const { DataSource } = require('typeorm'); +const { generateToken } = require('@tria-plc/api-common/utils/token'); + +const DEV_EMAIL = process.env.DEV_IAM_EMAIL || 'dev@edr.local'; + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: Number(process.env.DATABASE_PORT || 5432), + database: process.env.DATABASE_NAME, + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, +}); + +(async () => { + await ds.initialize(); + + // Upsert the dev user (users.email is UNIQUE). + const name = { en: 'Dev User', am: 'የሙከራ ተጠቃሚ' }; + const [user] = await ds.query( + `INSERT INTO iam.users (name, username, email, user_type, status, is_active) + VALUES ($1::jsonb, $2, $3, 'individual', 'accepted', true) + ON CONFLICT (email) DO UPDATE SET updated_at = now() + RETURNING id`, + [JSON.stringify(name), 'dev-user', DEV_EMAIL], + ); + const userId = user.id; + + // Fresh ACTIVE session; userInfo is the denormalized TCurrentUser the guard puts on req.user. + const sessionId = crypto.randomUUID(); + const userInfo = { + id: userId, + email: DEV_EMAIL, + name, + username: 'dev-user', + userType: 'individual', + status: 'accepted', + roles: [], + permissions: [], + }; + await ds.query( + `INSERT INTO iam.sessions (id, email, device, "userInfo", user_id, status, expiry_time) + VALUES ($1, $2, 'dev-seeder', $3::jsonb, $4, 'ACTIVE', now() + interval '7 days')`, + [sessionId, DEV_EMAIL, JSON.stringify(userInfo), userId], + ); + + // The package JwtGuard looks up the session by the token's `id` claim. + const token = generateToken({ id: sessionId }); + + console.log('\n=== IAM dev user seeded ==='); + console.log('user id :', userId); + console.log('email :', DEV_EMAIL); + console.log('session id:', sessionId); + console.log('\nBearer token (valid 7 days):\n' + token); + console.log('\nTry it: curl -H "Authorization: Bearer " http://localhost:3002/v1/auth/me'); + console.log('(Run again any time for a fresh token/session.)\n'); + + await ds.destroy(); +})().catch((e) => { + console.error('[seed-iam-dev-user] FAIL:', e.message); + process.exit(1); +}); diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 757432c3b..a4bce0d14 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -1,14 +1,31 @@ -import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { + MiddlewareConsumer, + Module, + NestModule, + OnApplicationBootstrap, +} from '@nestjs/common'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { APP_GUARD } from '@nestjs/core'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; +import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder'; +import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module'; +import { + EDR_PASSENGER_APPLICATION, + EDR_PASSENGER_PERMISSIONS, +} from './seed/edr-passenger.seed'; +import { EdrPassengerOrgSeeder } from './seed/edr-passenger-org.seeder'; +import { PassengerStaffUsersSeeder } from './seed/passenger-staff-users.seeder'; import { PrismaModule } from './common/prisma.module'; import { AuditModule } from './common/audit.module'; import { I18nModule } from './common/i18n/i18n.module'; -import { IamModule } from './common/iam.module'; import { LocaleMiddleware } from './common/i18n/locale.middleware'; import appConfig from './config/app.config'; import dbConfig from './config/database.config'; +import iamDatabaseConfig from './config/iam-database.config'; import telebirrConfig from './config/telebirr.config'; import cbeConfig from './config/cbe.config'; import ebirrConfig from './config/ebirr.config'; @@ -42,14 +59,24 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; import { AuditModuleFeature } from './modules/audit/audit.module'; import { CurrenciesModule } from './modules/currencies/currencies.module'; +import { SystemConfigModule } from './modules/system-config/system-config.module'; +import { PackagesModule } from './modules/packages/packages.module'; +import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; +import { HealthModule } from './modules/health/health.module'; @Module({ imports: [ + ThrottlerModule.forRoot([ + { name: 'auth', ttl: 60_000, limit: 5 }, + { name: 'strict', ttl: 60_000, limit: 20 }, + { name: 'default', ttl: 60_000, limit: 100 }, + ]), ConfigModule.forRoot({ isGlobal: true, load: [ appConfig, dbConfig, + iamDatabaseConfig, telebirrConfig, cbeConfig, ebirrConfig, @@ -61,11 +88,20 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): TypeOrmModuleOptions => + config.get('iamDatabase')!, + }), + TriaIamModule.forRoot({ + applications: [EDR_PASSENGER_APPLICATION], + permissions: EDR_PASSENGER_PERMISSIONS, + }), + SharedAuthModule, PrismaModule, AuditModule, I18nModule, - IamModule, - AuthModule, + AuthModule, StationsModule, FleetModule, SchedulesModule, @@ -91,10 +127,39 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; VerifaydaModule, AuditModuleFeature, CurrenciesModule, + SystemConfigModule, + PackagesModule, + ExcessBaggageModule, + HealthModule, + ], + providers: [ + { provide: APP_GUARD, useClass: ThrottlerGuard }, + EdrPassengerOrgSeeder, + PassengerStaffUsersSeeder, ], }) -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer.apply(LocaleMiddleware).forRoutes('*'); +export class AppModule implements OnApplicationBootstrap { + constructor( + private readonly seeder: DataSeeder, + private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, + private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, + ) {} + + async onApplicationBootstrap() { + try { + await this.seeder.run(); + } catch (err) { + console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); + } + try { + await this.edrPassengerOrgSeeder.run(); + } catch (err) { + console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); + } + try { + await this.passengerStaffUsersSeeder.run(); + } catch (err) { + console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); + } } } diff --git a/apps/edr-passenger-api/src/common/audit.service.ts b/apps/edr-passenger-api/src/common/audit.service.ts index 342e786bd..3f1dc161f 100644 --- a/apps/edr-passenger-api/src/common/audit.service.ts +++ b/apps/edr-passenger-api/src/common/audit.service.ts @@ -23,7 +23,7 @@ export class AuditService { await this.prisma.auditLog.create({ data: { - userId: input.userId, + iamUserId: input.userId, action: input.action, entityType: input.entityType, entityId: input.entityId, @@ -62,8 +62,7 @@ export class AuditService { if (filters.search) { where.OR = [ { entityId: { contains: filters.search, mode: 'insensitive' } }, - { user: { email: { contains: filters.search, mode: 'insensitive' } } }, - { user: { fullName: { contains: filters.search, mode: 'insensitive' } } }, + { iamUserId: { contains: filters.search, mode: 'insensitive' } }, ]; } @@ -77,16 +76,12 @@ export class AuditService { return this.prisma.auditLog.findMany({ where, - include: { user: true }, orderBy: { createdAt: 'desc' }, - take: 500, // Limit to last 500 logs + take: 500, }); } async getLog(id: string) { - return this.prisma.auditLog.findUnique({ - where: { id }, - include: { user: true }, - }); + return this.prisma.auditLog.findUnique({ where: { id } }); } } diff --git a/apps/edr-passenger-api/src/common/iam-adapter.spec.ts b/apps/edr-passenger-api/src/common/iam-adapter.spec.ts deleted file mode 100644 index d0c404366..000000000 --- a/apps/edr-passenger-api/src/common/iam-adapter.spec.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { IamGuard } from './iam-adapter'; -import { of, throwError } from 'rxjs'; - -describe('IamGuard', () => { - let guard: IamGuard; - let httpService: HttpService; - let configService: ConfigService; - let reflector: Reflector; - - const mockConfigService = { - get: jest.fn((key: string) => { - const config: Record = { - IAM_API_URL: 'https://iam.test.com/api', - IAM_ENABLED: 'true', - IAM_API_KEY: 'test-api-key', - }; - return config[key]; - }), - }; - - const mockHttpService = { - post: jest.fn(), - }; - - const mockReflector = { - get: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - IamGuard, - { provide: ConfigService, useValue: mockConfigService }, - { provide: HttpService, useValue: mockHttpService }, - { provide: Reflector, useValue: mockReflector }, - ], - }).compile(); - - guard = module.get(IamGuard); - httpService = module.get(HttpService); - configService = module.get(ConfigService); - reflector = module.get(Reflector); - - jest.clearAllMocks(); - }); - - const createMockContext = (token?: string, roles?: string[]): ExecutionContext => { - const request = { - headers: token ? { authorization: `Bearer ${token}` } : {}, - user: undefined, - }; - - return { - switchToHttp: () => ({ - getRequest: () => request, - }), - getHandler: () => ({}), - } as ExecutionContext; - }; - - describe('canActivate', () => { - it('should allow access when IAM is disabled', async () => { - mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED - - const context = createMockContext(); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - }); - - it('should throw UnauthorizedException when no token provided', async () => { - const context = createMockContext(); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should validate token and allow access', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: ['read', 'write'], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(null); - - const context = createMockContext('valid-token'); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(mockHttpService.post).toHaveBeenCalledWith( - 'https://iam.test.com/api/v1/auth/validate', - { token: 'valid-token' }, - expect.objectContaining({ - headers: expect.objectContaining({ - 'X-API-Key': 'test-api-key', - }), - }), - ); - }); - - it('should throw UnauthorizedException for invalid token', async () => { - const mockValidationResponse = { - data: { - valid: false, - error: 'Token expired', - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - - const context = createMockContext('invalid-token'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should check required roles', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'agent@test.com', - roles: ['AGENT'], - permissions: [], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']); - - const context = createMockContext('valid-token'); - - await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); - }); - - it('should allow access when user has required role', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: [], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']); - - const context = createMockContext('valid-token'); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - }); - - it('should handle HTTP errors gracefully', async () => { - mockHttpService.post.mockReturnValue( - throwError(() => new Error('Network error')), - ); - - const context = createMockContext('valid-token'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should attach user to request', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: ['read', 'write'], - organizationId: 'org-456', - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(null); - - const context = createMockContext('valid-token'); - await guard.canActivate(context); - - const request = context.switchToHttp().getRequest(); - expect(request.user).toEqual({ - userId: 'user-123', - email: 'admin@test.com', - roles: ['ADMIN'], - permissions: ['read', 'write'], - organizationId: 'org-456', - }); - }); - }); - - describe('token extraction', () => { - it('should extract token from Bearer header', async () => { - const mockValidationResponse = { - data: { - valid: true, - payload: { - sub: 'user-123', - email: 'test@test.com', - roles: [], - permissions: [], - exp: Date.now() + 3600000, - iat: Date.now(), - }, - }, - }; - - mockHttpService.post.mockReturnValue(of(mockValidationResponse)); - mockReflector.get.mockReturnValue(null); - - const context = createMockContext('my-token-123'); - await guard.canActivate(context); - - expect(mockHttpService.post).toHaveBeenCalledWith( - expect.any(String), - { token: 'my-token-123' }, - expect.any(Object), - ); - }); - - it('should reject malformed authorization header', async () => { - const request = { - headers: { authorization: 'InvalidFormat token' }, - }; - - const context = { - switchToHttp: () => ({ - getRequest: () => request, - }), - getHandler: () => ({}), - } as ExecutionContext; - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - }); -}); diff --git a/apps/edr-passenger-api/src/common/iam-adapter.ts b/apps/edr-passenger-api/src/common/iam-adapter.ts index fb32d9ec6..96168dba8 100644 --- a/apps/edr-passenger-api/src/common/iam-adapter.ts +++ b/apps/edr-passenger-api/src/common/iam-adapter.ts @@ -1,144 +1 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom } from 'rxjs'; - -/** - * IAM Adapter for @tria-plc corporate identity integration - * - * This adapter wraps the corporate IAM guards and provides a bridge - * between the corporate identity system and the EDR passenger API. - * - * For back-office roles (agent, supervisor, admin, staff), this guard - * validates tokens against the corporate IAM service. - * - * For passenger-facing routes, the existing JWT guard is used. - */ - -export interface IamTokenPayload { - sub: string; - email: string; - roles: string[]; - permissions: string[]; - organizationId?: string; - exp: number; - iat: number; -} - -export interface IamValidationResponse { - valid: boolean; - payload?: IamTokenPayload; - error?: string; -} - -@Injectable() -export class IamGuard implements CanActivate { - private readonly iamApiUrl: string; - private readonly iamEnabled: boolean; - - constructor( - private readonly reflector: Reflector, - private readonly config: ConfigService, - private readonly http: HttpService, - ) { - this.iamApiUrl = this.config.get('IAM_API_URL') || 'https://iam.tria-plc.com/api'; - this.iamEnabled = this.config.get('IAM_ENABLED') === 'true'; - } - - async canActivate(context: ExecutionContext): Promise { - if (!this.iamEnabled) { - // IAM disabled - allow access (for development) - return true; - } - - const request = context.switchToHttp().getRequest(); - const token = this.extractToken(request); - - if (!token) { - throw new UnauthorizedException('No authentication token provided'); - } - - const validation = await this.validateToken(token); - - if (!validation.valid || !validation.payload) { - throw new UnauthorizedException(validation.error || 'Invalid token'); - } - - // Check required roles - const requiredRoles = this.reflector.get('roles', context.getHandler()); - if (requiredRoles && requiredRoles.length > 0) { - const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role)); - if (!hasRole) { - throw new ForbiddenException('Insufficient permissions'); - } - } - - // Attach user to request - request.user = { - userId: validation.payload.sub, - email: validation.payload.email, - roles: validation.payload.roles, - permissions: validation.payload.permissions, - organizationId: validation.payload.organizationId, - }; - - return true; - } - - private extractToken(request: any): string | null { - const authHeader = request.headers.authorization; - if (!authHeader) return null; - - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') return null; - - return parts[1]; - } - - private async validateToken(token: string): Promise { - try { - const response = await firstValueFrom( - this.http.post( - `${this.iamApiUrl}/v1/auth/validate`, - { token }, - { - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': this.config.get('IAM_API_KEY') || '', - }, - timeout: 5000, - }, - ), - ); - - return response.data; - } catch (err) { - return { - valid: false, - error: err instanceof Error ? err.message : 'Token validation failed', - }; - } - } -} - -/** - * Decorator to mark routes as requiring IAM authentication - */ -export const UseIamAuth = () => { - // This is a marker decorator that can be used with @UseGuards(IamGuard) - return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => { - // Marker only - actual guard is applied via @UseGuards - }; -}; - -/** - * Decorator to specify required roles for IAM-protected routes - */ -export const IamRoles = (...roles: string[]) => { - return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => { - if (descriptor) { - Reflect.defineMetadata('roles', roles, descriptor.value); - } - }; -}; +export { JwtGuard as IamGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; diff --git a/apps/edr-passenger-api/src/common/iam-typeorm.config.ts b/apps/edr-passenger-api/src/common/iam-typeorm.config.ts new file mode 100644 index 000000000..ed19da12b --- /dev/null +++ b/apps/edr-passenger-api/src/common/iam-typeorm.config.ts @@ -0,0 +1,56 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import * as path from 'path'; + +/** + * TypeORM DataSource options for the shared `iam` schema. + * + * Context (see docs/iam-package-understanding-guide.md): + * - The `iam` schema is owned by `@tria-plc/iamapi-common` (TypeORM). Prisma owns the + * `passenger` schema. Both ORMs point at the same database (`edr_database`). + * - `@tria-plc/api-common`'s `JwtGuard` injects the *default* TypeORM `DataSource` and runs a + * raw `SELECT ... FROM iam.sessions`, so the app must expose a DataSource that can reach it. + * + * Connection env vars intentionally mirror the package's own migration DataSource + * (`@tria-plc/api-common/dist/modules/typeorm/typeorm.config.internal.js`) so the app and the + * package CLI read the same configuration: + * DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, DATABASE_SCHEMA + * + * This NEVER manages the schema: `synchronize: false` and `migrationsRun: false`. The `iam` + * schema is created by the IAM package migrations (dev: self-hosted; prod: central IAM team). + */ +function resolvePackageDist(pkg: string): string { + // Node honors each package's `exports` map at runtime even though TS `moduleResolution: "Node"` + // does not — so `require.resolve` on the barrel resolves to the package's dist `index.js`. + const resolved = require.resolve(pkg); + // Normalize to forward slashes so the glob works on Windows too. + return path.dirname(resolved).replace(/\\/g, '/'); +} + +export function buildIamTypeOrmOptions(): TypeOrmModuleOptions { + const iamDist = resolvePackageDist('@tria-plc/iamapi-common'); + // Some IAM entities (e.g. PositionType) relate to the notification entities that physically + // live in @tria-plc/api-common (the IAM barrel only re-exports them), so BOTH dist trees must + // be registered or TypeORM throws "Entity metadata ... was not found". + const apiDist = resolvePackageDist('@tria-plc/api-common'); + return { + type: 'postgres', + host: process.env.DATABASE_HOST, + port: Number(process.env.DATABASE_PORT ?? 5432), + database: process.env.DATABASE_NAME, + username: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + schema: process.env.DATABASE_SCHEMA ?? 'iam', + // IAM entities live in the packages; registered so the same default DataSource also serves + // IamModule in the dev self-host phase (Phase 3). Harmless before the tables exist. + entities: [ + `${iamDist}/entities/**/*.entity.{ts,js}`, + `${apiDist}/entities/**/*.entity.{ts,js}`, + ], + synchronize: false, + migrationsRun: false, + autoLoadEntities: false, + migrationsTableName: 'typeorm_migrations', + retryAttempts: process.env.IAM_ENABLED === 'true' ? 3 : 0, + logging: ['error'], + }; +} diff --git a/apps/edr-passenger-api/src/common/iam.module.ts b/apps/edr-passenger-api/src/common/iam.module.ts deleted file mode 100644 index 7a8ec9599..000000000 --- a/apps/edr-passenger-api/src/common/iam.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { IamGuard } from './iam-adapter'; - -@Global() -@Module({ - imports: [HttpModule.register({ timeout: 5000 })], - providers: [IamGuard], - exports: [IamGuard], -}) -export class IamModule {} diff --git a/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts b/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts index 9c962ba60..d5735231d 100644 --- a/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts +++ b/apps/edr-passenger-api/src/common/interceptors/session-activity.interceptor.ts @@ -1,7 +1,8 @@ -import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; -import { PrismaService } from '../prisma.service'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @Injectable() @@ -9,7 +10,7 @@ export class SessionActivityInterceptor implements NestInterceptor { private readonly inactivityMinutes: number; constructor( - private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, ) { this.inactivityMinutes = parseInt(this.config.get('SESSION_INACTIVITY_MINUTES') || '30', 10); @@ -18,29 +19,25 @@ export class SessionActivityInterceptor implements NestInterceptor { async intercept(context: ExecutionContext, next: CallHandler): Promise> { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); - const user = request.user; + const sessionId: string | undefined = request.user?.sessionId; - if (user?.userId) { - const session = await this.prisma.session.findFirst({ - where: { userId: user.userId }, - orderBy: { lastActivityAt: 'desc' }, - }); + if (sessionId) { + const rows = await this.dataSource.query>( + `SELECT expiry_time FROM iam.sessions WHERE id = $1 AND status = 'ACTIVE' LIMIT 1`, + [sessionId], + ); - if (session) { - const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000; - - if (inactiveMinutes > this.inactivityMinutes) { - await this.prisma.session.delete({ where: { id: session.id } }); - throw new UnauthorizedException('Session expired due to inactivity'); + if (rows.length) { + const minutesLeft = (rows[0].expiry_time.getTime() - Date.now()) / 60000; + if (minutesLeft < this.inactivityMinutes * 0.2) { + response.setHeader('X-Session-Expiry-Warning', Math.floor(minutesLeft).toString()); } - const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes); - response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString()); - - await this.prisma.session.update({ - where: { id: session.id }, - data: { lastActivityAt: new Date() }, - }); + // Extend session on every authenticated request + await this.dataSource.query( + `UPDATE iam.sessions SET expiry_time = NOW() + ($1 * INTERVAL '1 minute') WHERE id = $2 AND status = 'ACTIVE'`, + [this.inactivityMinutes, sessionId], + ); } } diff --git a/apps/edr-passenger-api/src/common/jwt.guard.ts b/apps/edr-passenger-api/src/common/jwt.guard.ts index f65f8455d..dfdeed190 100644 --- a/apps/edr-passenger-api/src/common/jwt.guard.ts +++ b/apps/edr-passenger-api/src/common/jwt.guard.ts @@ -1,5 +1,4 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class JwtGuard extends AuthGuard('jwt') {} +// Compatibility alias while passenger auth moves to @tria-plc IAM. +// Existing controllers can keep importing `../../common/jwt.guard`, but the +// guard now validates IAM-issued session tokens from `iam.sessions`. +export { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; diff --git a/apps/edr-passenger-api/src/common/jwt.strategy.ts b/apps/edr-passenger-api/src/common/jwt.strategy.ts deleted file mode 100644 index c1f532ba1..000000000 --- a/apps/edr-passenger-api/src/common/jwt.strategy.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { - const secret = config.get('JWT_SECRET'); - if (!secret) throw new Error('JWT_SECRET environment variable is not set'); - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: secret, - }); - } - async validate(payload: any) { - return { userId: payload.sub, email: payload.email, role: payload.role, passengerId: payload.passengerId }; - } -} diff --git a/apps/edr-passenger-api/src/common/passenger-guards.ts b/apps/edr-passenger-api/src/common/passenger-guards.ts new file mode 100644 index 000000000..cadd56889 --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-guards.ts @@ -0,0 +1,14 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerPermissionGuard } from './passenger-permission.guard'; +import { PASSENGER_PERMS } from '../seed/passenger-permissions.registry'; + +export const PassengerStaff = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission]), + ), + ); + +export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin); diff --git a/apps/edr-passenger-api/src/common/passenger-permission.guard.ts b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts new file mode 100644 index 000000000..a93b2060b --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts @@ -0,0 +1,30 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Type, + UnauthorizedException, +} from '@nestjs/common'; +import { hasPassengerPermission } from './passenger-permission.util'; + +export function PassengerPermissionGuard(permissions: string[]): Type { + @Injectable() + class PassengerPermissionsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: any }>(); + const user = request.user; + + if (!permissions?.length) return true; + if (!user) throw new UnauthorizedException('Authentication required'); + + if (permissions.some((p) => hasPassengerPermission(user, p))) return true; + + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return PassengerPermissionsGuard; +} diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts new file mode 100644 index 000000000..62df74603 --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -0,0 +1,74 @@ +import { ForbiddenException } from '@nestjs/common'; + +const SUPER_ADMIN_ROLE = 'super_admin'; +const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; + +type PermissionLike = { key?: string }; +type MeLikeUser = { + roles?: { key?: string }[]; + permissions?: PermissionLike[]; + employee?: + | { position?: { permissions?: PermissionLike[] }; delegatedPositions?: { permissions?: PermissionLike[] }[] } + | { positions?: { permissions?: PermissionLike[] }[] }[] + | null; +}; + +export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { + return user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE) ?? false; +} + +export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { + return user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE) ?? false; +} + +export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { + if (!user) return []; + + const keys = new Set(); + + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + + const employee = user.employee; + if (!employee) return [...keys]; + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; + } + + for (const p of employee.position?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const delegated of employee.delegatedPositions ?? []) { + for (const p of delegated.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + + return [...keys]; +} + +export function hasPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user) || isOrganizationAdmin(user)) return true; + return collectPermissionKeys(user).includes(permissionKey); +} + +export function assertPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): void { + if (hasPassengerPermission(user, permissionKey)) return; + throw new ForbiddenException(`Missing permission: ${permissionKey}`); +} diff --git a/apps/edr-passenger-api/src/common/roles.decorator.ts b/apps/edr-passenger-api/src/common/roles.decorator.ts index ec0c377c6..e038e1682 100644 --- a/apps/edr-passenger-api/src/common/roles.decorator.ts +++ b/apps/edr-passenger-api/src/common/roles.decorator.ts @@ -1,5 +1,4 @@ import { SetMetadata } from '@nestjs/common'; -import { UserRole } from '@prisma/client'; export const ROLES_KEY = 'roles'; -export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/edr-passenger-api/src/common/roles.guard.ts b/apps/edr-passenger-api/src/common/roles.guard.ts index 7b4b3eafc..b654bfa28 100644 --- a/apps/edr-passenger-api/src/common/roles.guard.ts +++ b/apps/edr-passenger-api/src/common/roles.guard.ts @@ -1,6 +1,5 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { UserRole } from '@prisma/client'; import { ROLES_KEY } from './roles.decorator'; @Injectable() @@ -8,12 +7,15 @@ export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); if (!requiredRoles) return true; const { user } = context.switchToHttp().getRequest(); - return requiredRoles.some((role) => user?.role === role); + // Support IAM roles array [{key, id}][] and legacy role string + return requiredRoles.some( + (role) => user?.roles?.some((r: { key: string }) => r.key === role) || user?.role === role, + ); } } diff --git a/apps/edr-passenger-api/src/config/iam-database.config.ts b/apps/edr-passenger-api/src/config/iam-database.config.ts new file mode 100644 index 000000000..4223a01c3 --- /dev/null +++ b/apps/edr-passenger-api/src/config/iam-database.config.ts @@ -0,0 +1,18 @@ +import { registerAs } from '@nestjs/config'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { buildIamTypeOrmOptions } from '../common/iam-typeorm.config'; + +/** + * Dedicated config namespace for the IAM **TypeORM** connection — the shared `iam` schema ONLY. + * + * This is intentionally separate from Prisma: Prisma remains the app's primary ORM and owns the + * `passenger` schema via `DATABASE_URL` (see prisma.service.ts). This second connection exists + * solely because `@tria-plc/api-common` / `@tria-plc/iamapi-common` are TypeORM-based and the + * `JwtGuard` reads `iam.sessions` through a TypeORM `DataSource`. + * + * Consumed by `TypeOrmModule.forRootAsync` in app.module.ts. + */ +export default registerAs( + 'iamDatabase', + (): TypeOrmModuleOptions => buildIamTypeOrmOptions(), +); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 928789c04..707a65fa4 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -1,6 +1,10 @@ +// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM +// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT), +// which happens before ConfigModule.forRoot() would populate it. Must be the very first import. +import "dotenv/config"; import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; -import { ValidationPipe } from "@nestjs/common"; +import { ValidationPipe, VersioningType } from "@nestjs/common"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { AppModule } from "./app.module"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; @@ -12,6 +16,11 @@ async function bootstrap() { // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. const app = await NestFactory.create(AppModule, { rawBody: true }); + // URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under + // `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay + // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. + app.enableVersioning({ type: VersioningType.URI }); + app.enableCors({ origin: [ process.env.PORTAL_URL ?? "http://localhost:5174", @@ -35,6 +44,9 @@ async function bootstrap() { Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. ## Latest Updates +- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting. +- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt. +- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers. - **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets). - **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this. - **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest). @@ -111,9 +123,10 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Gate validation with audit logs - Offline validation support - Multi-passenger tickets -- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) -- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets -- NEW: Complete audit trail per leg for compliance and reporting +- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) +- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets +- Complete audit trail per leg for compliance and reporting +- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode ### Booking Type Matrix @@ -253,9 +266,14 @@ Choose the right endpoint and bookingType: \`GET /payments/{paymentId}/status\` to confirm payment and retrieve tickets with QR codes ## Rate Limiting -- Auth endpoints: 5 requests/minute -- General endpoints: 100 requests/minute -- Webhook endpoints: No limit + +| Tier | Limit | Applied to | +|---|---|---| +| auth | 5 req/min | \`/auth\` (all), \`/fayda/verification\` (all) | +| strict | 20 req/min | \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\` | +| default | 100 req/min | All other endpoints | + +Exempt from rate limiting: \`/health/*\`, \`/internal/payments/*\`, payment webhook handlers. ## Error Handling All errors follow standard format: @@ -303,13 +321,14 @@ Payment providers send notifications to: .addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API") .addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations") .addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking") - .addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management") + .addTag("Health", "Liveness (GET /health), readiness with DB check (GET /health/ready), and app info (GET /health/info). All probes are public and exempt from rate limiting.") + .addTag("Internal Payments", "Service-to-service payment event handler (mark-paid). Requires service auth token. Exempt from rate limiting.") .addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals") .addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards") - .addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management") - .addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles") - .addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds") - .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation") + .addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management. Boarding pass email+SMS sent automatically on gate validation.") + .addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles. Rate limited: 20 req/min.") + .addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds. Rate limited: 20 req/min.") + .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation. Exempt from rate limiting.") .addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking") .addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards") .addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance") @@ -320,9 +339,9 @@ Payment providers send notifications to: .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") .addTag("Stations", "Station directory, location data, baggage facilities, and amenities") .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") - .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails") + .addTag("Tickets", "QR/barcode generation, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), audit trails. Boarding pass email+SMS sent automatically on every successful validation.") .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings") - .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") + .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger. Rate limited: 20 req/min.") //.addServer('http://localhost:4000', 'Development') // .addServer("https://api.edr-platform.com", "Production") .build(); 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 aa23fe6d0..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,40 +1,40 @@ -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'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; -import { UserRole } from '@prisma/client'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; @ApiTags('Agents') @Controller('agents') -@UseGuards(IamGuard) +@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') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { return this.service.createAgentBooking(dto); } @Post('shifts/open') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Open agent shift' }) openShift(@Body() dto: OpenShiftDto) { return this.service.openShift(dto); } @Post('shifts/close') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Close agent shift' }) closeShift(@Body() dto: CloseShiftDto) { return this.service.closeShift(dto); } @Get(':agentId/commissions') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Get agent commissions' }) getCommissions( @Param('agentId') agentId: string, @@ -49,7 +49,6 @@ export class AgentsController { } @Get(':agentId/shifts') - @IamRoles('AGENT', 'ADMIN') @ApiOperation({ summary: 'Get agent shifts' }) getShifts(@Param('agentId') agentId: string) { return this.service.getShifts(agentId); 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 12982f570..4d4b593fb 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -13,9 +13,13 @@ export class AgentsService { constructor(private prisma: PrismaService) {} async createAgentBooking(dto: CreateAgentBookingDto) { - const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } }); + const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } }); if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive'); - if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account'); + + const passenger = agent.iamUserId + ? await this.prisma.passenger.findUnique({ where: { iamUserId: agent.iamUserId } }) + : null; + if (!passenger) throw new BadRequestException('Agent must have a linked passenger account'); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -30,7 +34,7 @@ export class AgentsService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: agent.user.passenger.id, + passengerId: passenger.id, scheduleId: dto.scheduleId, status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT', totalMinor, @@ -129,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/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 37bc89855..1202e4d45 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -1,11 +1,12 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { AuditService } from '../../common/audit.service'; -import { IamGuard } from '../../common/iam-adapter'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Audit') @Controller('audit') -@UseGuards(IamGuard) +@PassengerStaff([PASSENGER_PERMS.audit.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class AuditController { constructor(private auditService: AuditService) {} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index 0565faf8f..80e67d3de 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -1,303 +1,121 @@ -import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common'; +import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Patch, Delete, Param, Request, Query, UnauthorizedException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger'; -import { AuthService } from './auth.service'; -import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto'; +import { Throttle, SkipThrottle } from '@nestjs/throttler'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { PassengerAuthService } from './passenger-auth.service'; +import { RegisterDto, LoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { RolesGuard } from '../../common/roles.guard'; -import { Roles } from '../../common/roles.decorator'; -import { UserRole } from '@prisma/client'; @ApiTags('Auth') @Controller('auth') +@Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class AuthController { - constructor(private service: AuthService) {} + constructor(private passengerAuthService: PassengerAuthService) {} @Post('register') - @ApiOperation({ - summary: 'Register new passenger account', - description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.' - }) - @ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' }) - @ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' }) + @IsPublic() + @ApiOperation({ summary: 'Register new passenger account' }) + @ApiResponse({ status: 201, description: 'Account created. Returns token + user.' }) @ApiResponse({ status: 409, description: 'Email or phone already registered' }) @ApiBody({ type: RegisterDto }) - register(@Body() dto: RegisterDto) { return this.service.register(dto); } + register(@Request() req: any, @Body() dto: RegisterDto) { + return this.passengerAuthService.register(dto, req); + } @Post('login') + @IsPublic() @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Login with email and password', - description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.' - }) - @ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' }) - @ApiResponse({ status: 401, description: 'Invalid credentials or account locked' }) - @ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' }) + @ApiOperation({ summary: 'Login with email and password' }) + @ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) @ApiBody({ type: LoginDto }) - login(@Body() dto: LoginDto) { return this.service.login(dto); } - - @Post('otp/request') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Request OTP verification code', - description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.' - }) - @ApiResponse({ status: 200, description: 'OTP sent successfully to email' }) - @ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' }) - @ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' }) - @ApiBody({ type: RequestOtpDto }) - requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); } - - @Post('otp/verify') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Verify OTP code', - description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.' - }) - @ApiResponse({ status: 200, description: 'OTP verified successfully' }) - @ApiResponse({ status: 400, description: 'Invalid or expired OTP code' }) - @ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' }) - @ApiBody({ type: VerifyOtpDto }) - verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); } - - @Post('password/reset-request') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Request password reset link', - description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.' - }) - @ApiResponse({ status: 200, description: 'Password reset email sent successfully' }) - @ApiResponse({ status: 404, description: 'Email not found' }) - @ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' }) - @ApiBody({ type: RequestPasswordResetDto }) - requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); } - - @Post('password/reset') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Reset password with token', - description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.' - }) - @ApiResponse({ status: 200, description: 'Password reset successfully' }) - @ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' }) - @ApiResponse({ status: 404, description: 'User not found' }) - @ApiBody({ type: ResetPasswordDto }) - resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); } + login(@Request() req: any, @Body() dto: LoginDto) { + return this.passengerAuthService.login(dto, req); + } @Post('logout') @HttpCode(HttpStatus.OK) @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Logout current user', - description: `Logout the authenticated user and invalidate their session. + @ApiOperation({ summary: 'Logout current user' }) + @ApiResponse({ status: 200, description: 'Logout successful' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + logout(@Request() req: any) { + if (!req.user?.id) throw new UnauthorizedException('User not authenticated'); + return this.passengerAuthService.logout(req.user, req); + } -### What happens: -- Invalidates the current session token -- Records logout in audit log -- Frontend should clear stored token and redirect to home - -### Authentication: -- **Required**: JWT Bearer Token -- Token will be invalidated after successful logout` - }) - @ApiResponse({ - status: 200, - description: 'Logout successful', - schema: { - example: { - success: true, - message: 'Logged out successfully' - } - } - }) - @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) - logout(@Request() req: any) { - if (!req.user || !req.user.userId) { - throw new UnauthorizedException('User not authenticated'); - } - return this.service.logout(req.user.userId); + @Get('me') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: '[DEV] Inspect raw JWT payload — shows full req.user from JwtGuard' }) + @ApiResponse({ status: 200, description: 'Returns the full req.user object set by JwtGuard' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + getMe(@Request() req: any) { + return { user: req.user }; } @Get('profile') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Get current user profile', - description: `**Returns complete user profile with all connected data** - ---- - -### Response Includes - -#### User Information -- Basic details (id, email, phone, fullName, role) -- Nationality and document information -- Fayda verification status -- Account timestamps (created, last login) - -#### Passenger Data (if role=PASSENGER) -- Passenger ID and preferences -- **Loyalty Account**: Tier, points balance, lifetime points -- **Wallet Account**: Balance (minor units), currency - -#### Devices -- List of registered devices with platform, name, push token, and last seen time - -#### User Preferences -- Language, notification settings, etc. - ---- - -### Use Cases - -1. **App Initialization**: Fetch on app load to get user context - -2. **Profile Pre-fill**: Use data to auto-fill booking forms - -3. **Verification Check**: Check \`faydaVerified\` before registration - -4. **Loyalty Display**: Show tier and points in UI - -5. **Wallet Balance**: Display available balance - -6. **Device Management**: Get list of user's registered devices - ---- - -### Authentication -- **Required**: JWT Bearer Token -- Token must be valid and not expired -- Returns profile for authenticated user only`, - }) - @ApiResponse({ - status: 200, - description: 'User profile retrieved successfully', - schema: { - example: { - id: 'user-uuid-123', - email: 'kelemu@email.com', - phone: '+251911234567', - fullName: 'Kelemu Abebe', - role: 'PASSENGER', - nationality: 'Ethiopian', - nationalityCode: 'ET', - nationalId: null, - passportNumber: null, - faydaVerified: true, - faydaVerifiedAt: '2024-01-15T10:30:00.000Z', - lastLoginAt: '2024-01-20T14:22:00.000Z', - createdAt: '2023-12-01T08:00:00.000Z', - passenger: { - id: 'passenger-uuid-456', - preferredLanguage: 'am', - loyalty: { - tier: 'SILVER', - pointsBalance: 1500, - lifetimePoints: 3000 - }, - wallet: { - balanceMinor: 50000, - currency: 'ETB' - } - }, - preferences: { - emailNotifications: true, - smsNotifications: true, - language: 'am' - }, - devices: [ - { - id: 'device-uuid-1', - platform: 'WEB', - name: 'Chrome on Windows', - pushToken: 'token-abc123', - trusted: true, - lastSeenAt: '2024-01-20T14:22:00.000Z' - }, - { - id: 'device-uuid-2', - platform: 'IOS', - name: 'iPhone 14', - pushToken: 'token-xyz789', - trusted: false, - lastSeenAt: '2024-01-19T10:15:00.000Z' - } - ] - } - } - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - Invalid or missing JWT token', - schema: { - example: { - statusCode: 401, - message: 'Unauthorized' - } - } - }) - getProfile(@Request() req: any) { - console.log('Profile request - User from JWT:', req.user); - if (!req.user || !req.user.userId) { - throw new UnauthorizedException('User not authenticated'); - } - return this.service.getProfile(req.user.userId); + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'User profile retrieved successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + getProfile(@Request() req: any) { + const userId = req.user?.id; + if (!userId) throw new UnauthorizedException('User not authenticated'); + return this.passengerAuthService.getProfile(userId); } + // TODO: admin user management endpoints — implement when admin module is ready + @Get('users') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' }) - getUsers( + @ApiOperation({ summary: 'List all users (admin)' }) + listUsers( @Query('search') search?: string, @Query('role') role?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.service.getUsers({ - search, - role, - status, - page: page ? parseInt(page) : 1, - pageSize: pageSize ? parseInt(pageSize) : 10, + return this.passengerAuthService.listUsers({ + search, role, status, + page: page ? +page : 1, + pageSize: pageSize ? +pageSize : 20, }); } @Post('users') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' }) - createUser(@Body() dto: any) { - return this.service.createUser(dto); + @ApiOperation({ summary: 'Create user (admin)' }) + createUser(@Body() body: any) { + return this.passengerAuthService.createUser(body); } @Patch('users/:id') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' }) - updateUser(@Param('id') id: string, @Body() dto: any) { - return this.service.updateUser(id, dto); + @ApiOperation({ summary: 'Update user (admin)' }) + updateUser(@Param('id') id: string, @Body() body: any) { + return this.passengerAuthService.updateUser(id, body); } @Delete('users/:id') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Delete backoffice user (admin only)' }) + @ApiOperation({ summary: 'Delete user (admin)' }) deleteUser(@Param('id') id: string) { - return this.service.deleteUser(id); + return this.passengerAuthService.deleteUser(id); } @Post('users/:id/reset-password') - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR) + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' }) - resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) { - return this.service.resetUserPassword(id, dto.tempPassword); + @ApiOperation({ summary: 'Reset user password (admin)' }) + resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) { + return this.passengerAuthService.resetUserPassword(id, body.tempPassword); } } diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index d44159c67..e12c8cd06 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -1,152 +1,51 @@ -import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class NameDto { + @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) + @IsString() + am: string; + + @ApiProperty({ example: 'Kelemu Ketsela' }) + @IsString() + en: string; +} export class RegisterDto { - @ApiProperty({ - description: 'Full name of the passenger', - example: 'Kelemu Ketsela', - minLength: 2, - maxLength: 100 - }) - @IsString() - fullName: string; - - @ApiProperty({ - description: 'Email address (must be unique)', - example: 'kelemu@email.com', - format: 'email' - }) - @IsEmail() + @ApiProperty({ example: 'kelemu@email.com' }) + @IsEmail() email: string; - @ApiProperty({ - description: 'Phone number with country code', - example: '+251912345678', - pattern: '^\\+[1-9]\\d{1,14}$' - }) - @IsString() - phone: string; + @ApiProperty({ example: 'kelemu.ketsela' }) + @IsString() + username: string; - @ApiProperty({ - description: 'Password (minimum 8 characters)', - example: 'SecurePass123', - minLength: 8, - format: 'password' - }) - @IsString() - @MinLength(8) + @ApiProperty({ example: '+251912345678' }) + @IsString() + phoneNumber: string; + + @ApiProperty({ type: NameDto }) + @ValidateNested() + @Type(() => NameDto) + name: NameDto; + + @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' }) + @IsString() + @MinLength(8) password: string; - @ApiPropertyOptional({ - description: 'Nationality of the passenger', - example: 'Ethiopian' - }) - @IsOptional() - @IsString() - nationality?: string; - - @ApiPropertyOptional({ - description: 'National ID number', - example: 'ET123456789' - }) - @IsOptional() - @IsString() - nationalId?: string; - - @ApiPropertyOptional({ - description: 'Passport number for international travelers', - example: 'P1234567' - }) - @IsOptional() - @IsString() - passportNumber?: string; + @ApiProperty({ example: 'SecurePass123', format: 'password' }) + @IsString() + confirmPassword: string; } export class LoginDto { - @ApiProperty({ - description: 'Registered email address', - example: 'kelemu@email.com', - format: 'email' - }) - @IsEmail() + @ApiProperty({ example: 'kelemu@email.com' }) + @IsEmail() email: string; - @ApiProperty({ - description: 'Account password', - example: 'password123', - format: 'password' - }) - @IsString() + @ApiProperty({ example: 'password123', format: 'password' }) + @IsString() password: string; } - -export class RequestOtpDto { - @ApiProperty({ - description: 'Email address to send OTP', - example: 'kelemu@email.com' - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)', - example: 'REGISTRATION', - enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION'] - }) - @IsString() - purpose: string; -} - -export class VerifyOtpDto { - @ApiProperty({ - description: 'Email address', - example: 'kelemu@email.com' - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: '6-digit OTP code', - example: '123456', - minLength: 6, - maxLength: 6 - }) - @IsString() - code: string; - - @ApiProperty({ - description: 'Purpose of OTP verification', - example: 'REGISTRATION', - enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION'] - }) - @IsString() - purpose: string; -} - -export class RequestPasswordResetDto { - @ApiProperty({ - description: 'Email address of the account', - example: 'kelemu@email.com' - }) - @IsEmail() - email: string; -} - -export class ResetPasswordDto { - @ApiProperty({ - description: 'Password reset token received via email', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' - }) - @IsString() - token: string; - - @ApiProperty({ - description: 'New password (minimum 8 characters)', - example: 'NewSecurePass123', - minLength: 8, - format: 'password' - }) - @IsString() - @MinLength(8) - newPassword: string; -} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.module.ts b/apps/edr-passenger-api/src/modules/auth/auth.module.ts index 547937d74..54357df06 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.module.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.module.ts @@ -1,24 +1,10 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; -import { AuthService } from './auth.service'; -import { JwtStrategy } from '../../common/jwt.strategy'; +import { PassengerAuthService } from './passenger-auth.service'; @Module({ - imports: [ - PassportModule, - JwtModule.registerAsync({ - inject: [ConfigService], - useFactory: (c: ConfigService) => ({ - secret: c.get('JWT_SECRET'), - signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') }, - }), - }), - ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [JwtModule], + providers: [PassengerAuthService], + exports: [PassengerAuthService], }) export class AuthModule {} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.service.ts b/apps/edr-passenger-api/src/modules/auth/auth.service.ts deleted file mode 100644 index e937a106b..000000000 --- a/apps/edr-passenger-api/src/modules/auth/auth.service.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { PrismaService } from '../../common/prisma.service'; -import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto'; -import * as bcrypt from 'bcrypt'; -import * as crypto from 'crypto'; - -@Injectable() -export class AuthService { - constructor(private prisma: PrismaService, private jwt: JwtService) {} - - async register(dto: RegisterDto) { - const exists = await this.prisma.user.findFirst({ - where: { OR: [{ email: dto.email }, { phone: dto.phone }] }, - }); - if (exists) throw new ConflictException('Email or phone already registered'); - const passwordHash = await bcrypt.hash(dto.password, 10); - const user = await this.prisma.user.create({ - data: { - fullName: dto.fullName, - email: dto.email, - phone: dto.phone, - passwordHash, - nationality: dto.nationality, - nationalId: dto.nationalId, - passportNumber: dto.passportNumber - }, - }); - const passenger = await this.prisma.passenger.create({ data: { userId: user.id } }); - await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } }); - await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } }); - await this.prisma.userPreferences.create({ data: { userId: user.id } }); - await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email }); - return await this.signToken(user.id, user.email, user.role, passenger.id); - } - - async login(dto: LoginDto) { - const user = await this.prisma.user.findUnique({ - where: { email: dto.email }, - include: { passenger: true, agent: true }, - }); - if (!user) throw new UnauthorizedException('Invalid credentials'); - - if (user.lockedUntil && user.lockedUntil > new Date()) { - throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`); - } - - if (!(await bcrypt.compare(dto.password, user.passwordHash))) { - await this.prisma.user.update({ - where: { id: user.id }, - data: { - failedLoginAttempts: { increment: 1 }, - lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null - } - }); - throw new UnauthorizedException('Invalid credentials'); - } - - await this.prisma.user.update({ - where: { id: user.id }, - data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() } - }); - - await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null); - - // Ensure passenger exists and get its ID - let passengerId = user.passenger?.id; - if (!passengerId) { - // If passenger doesn't exist, create it - const passenger = await this.prisma.passenger.create({ - data: { userId: user.id } - }); - passengerId = passenger.id; - // Also create loyalty and wallet accounts - await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } }); - await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } }); - } - - return await this.signToken(user.id, user.email, user.role, passengerId, user.agent?.id); - } - - async requestOtp(dto: RequestOtpDto) { - const code = Math.floor(100000 + Math.random() * 900000).toString(); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - await this.prisma.otpCode.create({ - data: { email: dto.email, code, purpose: dto.purpose, expiresAt } - }); - console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`); - return { sent: true, expiresIn: 600 }; - } - - async verifyOtp(dto: VerifyOtpDto) { - const otp = await this.prisma.otpCode.findFirst({ - where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } }, - orderBy: { createdAt: 'desc' } - }); - if (!otp) throw new BadRequestException('Invalid or expired OTP'); - await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } }); - return { verified: true }; - } - - async requestPasswordReset(dto: RequestPasswordResetDto) { - const user = await this.prisma.user.findUnique({ where: { email: dto.email } }); - if (!user) return { sent: true }; - const token = crypto.randomBytes(32).toString('hex'); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); - await this.prisma.passwordResetToken.create({ - data: { userId: user.id, token, expiresAt } - }); - console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`); - return { sent: true }; - } - - async resetPassword(dto: ResetPasswordDto) { - const resetToken = await this.prisma.passwordResetToken.findUnique({ - where: { token: dto.token } - }); - if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) { - throw new BadRequestException('Invalid or expired reset token'); - } - const passwordHash = await bcrypt.hash(dto.newPassword, 10); - await this.prisma.user.update({ - where: { id: resetToken.userId }, - data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null } - }); - await this.prisma.passwordResetToken.update({ - where: { id: resetToken.id }, - data: { used: true } - }); - await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null); - return { reset: true }; - } - - async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) { - const { search, role, status, page = 1, pageSize = 10 } = filters; - const skip = (page - 1) * pageSize; - - const where: any = { - role: { not: 'PASSENGER' }, // Exclude passenger accounts - }; - - if (search) { - where.OR = [ - { email: { contains: search, mode: 'insensitive' } }, - { fullName: { contains: search, mode: 'insensitive' } }, - ]; - } - - if (role) { - where.role = role; - } - - // For status filtering, we check if user is active (no lock/block) or inactive - if (status === 'ACTIVE') { - where.AND = [ - { blockedUntil: { lte: new Date() } }, - { lockedUntil: { lte: new Date() } } - ]; - } else if (status === 'INACTIVE') { - where.OR = [ - { blockedUntil: { gt: new Date() } }, - { lockedUntil: { gt: new Date() } } - ]; - } - - const [items, total] = await Promise.all([ - this.prisma.user.findMany({ - where, - select: { - id: true, - email: true, - fullName: true, - role: true, - lastLoginAt: true, - createdAt: true, - blockedUntil: true, - lockedUntil: true, - }, - skip, - take: pageSize, - orderBy: { createdAt: 'desc' }, - }), - this.prisma.user.count({ where }), - ]); - - return { - items: items.map(user => ({ - id: user.id, - email: user.email, - fullName: user.fullName, - role: user.role, - lastLogin: user.lastLoginAt, - status: (!user.blockedUntil || user.blockedUntil <= new Date()) && - (!user.lockedUntil || user.lockedUntil <= new Date()) - ? 'ACTIVE' - : 'INACTIVE', - })), - total, - page, - pageSize, - }; - } - - async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) { - const exists = await this.prisma.user.findFirst({ - where: { OR: [{ email: dto.email }] }, - }); - if (exists) throw new ConflictException('Email already registered'); - - const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10); - - const user = await this.prisma.user.create({ - data: { - email: dto.email, - fullName: dto.fullName, - role: dto.role as any, - phone: dto.email, // Use email as phone temporarily for unique constraint - passwordHash, - blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined, - }, - select: { - id: true, - email: true, - fullName: true, - role: true, - lastLoginAt: true, - createdAt: true, - }, - }); - - await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role }); - - return user; - } - - async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) { - const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new NotFoundException('User not found'); - - const updateData: any = {}; - if (dto.fullName) updateData.fullName = dto.fullName; - if (dto.role) updateData.role = dto.role; - if (dto.status === 'ACTIVE') { - updateData.blockedUntil = null; - updateData.lockedUntil = null; - } else if (dto.status === 'INACTIVE') { - updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); - } - - const updated = await this.prisma.user.update({ - where: { id }, - data: updateData, - select: { - id: true, - email: true, - fullName: true, - role: true, - lastLoginAt: true, - createdAt: true, - }, - }); - - await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData }); - - return updated; - } - - async deleteUser(id: string) { - const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new NotFoundException('User not found'); - - // Don't actually delete, just deactivate - await this.prisma.user.update({ - where: { id }, - data: { blockedUntil: new Date(), lockedUntil: new Date() }, - }); - - await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null); - - return { deleted: true }; - } - - async resetUserPassword(id: string, tempPassword: string) { - const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new NotFoundException('User not found'); - - const passwordHash = await bcrypt.hash(tempPassword, 10); - await this.prisma.user.update({ - where: { id }, - data: { - passwordHash, - failedLoginAttempts: 0, - lockedUntil: null, - }, - }); - - await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' }); - - return { reset: true, tempPassword }; - } - - private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) { - // Get the full user data to include fullName - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { id: true, email: true, fullName: true, role: true } - }); - - const payload = { sub: userId, email, role, passengerId, agentId }; - console.log('[AUTH] Creating JWT with payload:', payload); - - const token = this.jwt.sign(payload); - console.log('[AUTH] JWT created, token length:', token.length); - - const response = { - token, - user: { - id: userId, - email, - fullName: user?.fullName || email, - role, - passengerId, - agentId - } - }; - console.log('[AUTH] Returning user object with passengerId:', response.user.passengerId); - return response; - } - - private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) { - await this.prisma.auditLog.create({ - data: { userId, action, entityType, entityId, oldData, newData } - }); - } - - async getProfile(userId: string) { - if (!userId) { - throw new UnauthorizedException('User ID not found in token'); - } - - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { - passenger: { - include: { - loyalty: true, - wallet: true, - }, - }, - preferences: true, - devices: true, - }, - }); - - if (!user) throw new UnauthorizedException('User not found'); - - return { - id: user.id, - email: user.email, - phone: user.phone, - fullName: user.fullName, - role: user.role, - nationality: user.nationality, - nationalityCode: user.nationalityCode, - nationalId: user.nationalId, - passportNumber: user.passportNumber, - faydaVerified: user.faydaVerified, - faydaVerifiedAt: user.faydaVerifiedAt, - lastLoginAt: user.lastLoginAt, - createdAt: user.createdAt, - passenger: user.passenger ? { - id: user.passenger.id, - preferredLanguage: user.passenger.preferredLanguage, - loyalty: user.passenger.loyalty ? { - tier: user.passenger.loyalty.tier, - pointsBalance: user.passenger.loyalty.pointsBalance, - lifetimePoints: user.passenger.loyalty.lifetimePoints, - } : null, - wallet: user.passenger.wallet ? { - balanceMinor: user.passenger.wallet.balanceMinor, - currency: user.passenger.wallet.currency, - } : null, - } : null, - preferences: user.preferences, - devices: user.devices.map(device => ({ - id: device.id, - platform: device.platform, - name: device.name, - pushToken: device.pushToken, - trusted: device.trusted, - lastSeenAt: device.lastSeenAt, - })), - }; - } - - async logout(userId: string) { - // Invalidate all active sessions for this user - await this.prisma.session.deleteMany({ - where: { userId } - }); - - // Log the logout action - await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null); - - return { - success: true, - message: 'Logged out successfully' - }; - } -} diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts new file mode 100644 index 000000000..4192091dd --- /dev/null +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -0,0 +1,434 @@ +import { + Injectable, + ConflictException, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; +import { ModuleRef, ContextIdFactory } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service'; +import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; +import { PrismaService } from '../../common/prisma.service'; +import { RegisterDto, LoginDto } from './auth.dto'; + +type IamUserRow = { + id: string; + email: string; + name: { en: string; am: string } | null; + phone_number: string | null; + metadata: Record | null; +}; + +@Injectable() +export class PassengerAuthService { + constructor( + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly moduleRef: ModuleRef, + private readonly eventEmitter: EventEmitter2, + ) {} + + private async resolveIamAuthService(req: any): Promise { + const contextId = ContextIdFactory.getByRequest(req); + this.moduleRef.registerRequestByContextId(req, contextId); + return this.moduleRef.resolve(IamAuthService, contextId, { strict: false }); + } + + async register(dto: RegisterDto, req: any) { + const existing = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (existing.length) throw new ConflictException('Email or phone already registered'); + + const iamAuthService = await this.resolveIamAuthService(req); + + const { token, refreshToken } = await iamAuthService.signupWithPassword({ + email: dto.email, + username: dto.username, + phoneNumber: dto.phoneNumber, + userType: EUserType.INDIVIDUAL, + name: dto.name, + password: dto.password, + confirmPassword: dto.confirmPassword, + }); + + const iamRows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, + [dto.email], + ); + if (!iamRows.length) { + await this.compensateIamSignup(dto.email); + throw new InternalServerErrorException('Account creation failed. Please try again.'); + } + const iamUserId = iamRows[0].id; + + let passengerId: string; + try { + const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' }); + passengerId = result.passengerId; + } catch { + await this.compensateIamSignup(dto.email); + throw new InternalServerErrorException('Account creation failed. Please try again.'); + } + + return { + token, + refreshToken, + user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId }, + }; + } + + async login(dto: LoginDto, req: any) { + const iamAuthService = await this.resolveIamAuthService(req); + + let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean }; + try { + iamResult = await iamAuthService.login({ email: dto.email, password: dto.password }); + } catch { + this.eventEmitter.emit('auth.login.failed', { email: dto.email }); + throw new UnauthorizedException('Invalid credentials'); + } + + if ('mfaRequired' in iamResult && iamResult.mfaRequired) { + return iamResult; + } + + const { token, refreshToken } = iamResult as { token: string; refreshToken: string }; + + const iamRows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, + [dto.email], + ); + const iamUser = iamRows[0]; + if (!iamUser) { + throw new InternalServerErrorException('IAM user not found after successful authentication'); + } + + // Find existing Passenger record or lazy-provision one on first login + let passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: iamUser.id }, + select: { id: true }, + }); + + if (!passenger) { + const result = await this.provisionPassengerSatellite({ + iamUserId: iamUser.id, + auditAction: 'USER_AUTO_PROVISIONED', + }); + passenger = { id: result.passengerId }; + } + + return { + token, + refreshToken, + user: { id: iamUser.id, iamUserId: iamUser.id, email: dto.email, passengerId: passenger.id }, + }; + } + + private async provisionPassengerSatellite(data: { + iamUserId: string; + auditAction: string; + }): Promise<{ passengerId: string }> { + return this.prisma.$transaction(async (tx) => { + const passenger = await tx.passenger.create({ + data: { iamUserId: data.iamUserId }, + }); + await tx.loyaltyAccount.create({ data: { passengerId: passenger.id } }); + await tx.walletAccount.create({ data: { passengerId: passenger.id } }); + await tx.userPreferences.create({ data: { iamUserId: data.iamUserId } }); + await tx.auditLog.create({ + data: { + iamUserId: data.iamUserId, + action: data.auditAction, + entityType: 'User', + entityId: data.iamUserId, + newData: { iamUserId: data.iamUserId }, + }, + }); + return { passengerId: passenger.id }; + }); + } + + async logout(user: any, req: any) { + const iamAuthService = await this.resolveIamAuthService(req); + await iamAuthService.logout(user); + return { success: true, message: 'Logged out successfully' }; + } + + async getProfile(iamUserId: string) { + const [passenger, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ + where: { iamUserId }, + include: { loyalty: true, wallet: true }, + }), + this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ), + ]); + + if (!passenger) throw new Error('Passenger not found'); + const iam = iamRows[0]; + + return { + iamUserId, + email: iam?.email ?? null, + phone: iam?.phone_number ?? null, + fullName: iam?.name?.en ?? iam?.name?.am ?? null, + faydaVerified: iam?.metadata?.faydaVerified ?? false, + createdAt: passenger.createdAt, + passenger: { + id: passenger.id, + preferredLanguage: passenger.preferredLanguage, + loyalty: passenger.loyalty + ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: passenger.loyalty.lifetimePoints } + : null, + wallet: passenger.wallet + ? { balanceMinor: passenger.wallet.balanceMinor, currency: passenger.wallet.currency } + : null, + }, + }; + } + + async listUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) { + const page = filters.page ?? 1; + const pageSize = filters.pageSize ?? 20; + const offset = (page - 1) * pageSize; + + const params: any[] = []; + const conditions: string[] = []; + + if (filters.search) { + params.push(`%${filters.search}%`); + conditions.push(`(u.email ILIKE $${params.length} OR (u.name->>'en') ILIKE $${params.length})`); + } + if (filters.role) { + params.push(`%${filters.role}%`); + conditions.push(`r.key ILIKE $${params.length}`); + } + if (filters.status) { + const active = filters.status === 'ACTIVE'; + params.push(active); + conditions.push(`u.is_active = $${params.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + + const baseQuery = ` + FROM iam.users u + LEFT JOIN iam.user_roles ur ON ur.user_id = u.id + LEFT JOIN iam.roles r ON r.id = ur.role_id + ${where} + `; + + const countParams = [...params]; + const [rows, countRows] = await Promise.all([ + this.dataSource.query( + `SELECT DISTINCT u.id, u.email, u.name, u.phone_number, u.is_active, u.status, u.created_at, + r.key as role_key, r.name as role_name + ${baseQuery} + ORDER BY u.created_at DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, pageSize, offset], + ), + this.dataSource.query( + `SELECT COUNT(DISTINCT u.id) as count ${baseQuery}`, + countParams, + ), + ]); + + const items = rows.map((u: any) => ({ + id: u.id, + email: u.email, + fullName: u.name?.en ?? u.name?.am ?? '', + role: u.role_key ?? '', + status: u.is_active ? 'ACTIVE' : 'INACTIVE', + lastLogin: u.metadata?.lastLogin ?? null, + createdAt: u.created_at, + })); + + return { items, total: parseInt(countRows[0]?.count ?? '0'), page, pageSize }; + } + + async createUser(data: { email: string; fullName: string; role: string; password: string; status?: string }) { + const existing = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, + [data.email], + ); + if (existing.length) throw new ConflictException('Email already registered'); + + // Derive username from email local-part; ensure uniqueness by appending a short suffix if taken + const baseUsername = data.email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, ''); + const taken = await this.dataSource.query<{ username: string }[]>( + `SELECT username FROM iam.users WHERE username LIKE $1 LIMIT 10`, + [`${baseUsername}%`], + ); + const takenSet = new Set(taken.map((r) => r.username)); + let username = baseUsername; + let suffix = 1; + while (takenSet.has(username)) { + username = `${baseUsername}${suffix++}`; + } + + // Hash with argon2 — same algorithm the IAM login uses (verifyPassword in auth.service.js) + const { hashPassword } = await import('@tria-plc/api-common/utils/argon'); + const passwordHash = await hashPassword(data.password); + + await this.dataSource.query( + `INSERT INTO iam.users (email, username, name, user_type, status, is_active) + VALUES ($1, $2, $3::jsonb, 'employee', $4, $5)`, + [ + data.email, + username, + JSON.stringify({ en: data.fullName, am: data.fullName }), + data.status === 'INACTIVE' ? 'pending' : 'accepted', + data.status !== 'INACTIVE', + ], + ); + + // Insert credential with correct column `password` and is_active = true + // so the IAM login SQL (find-user-for-login.sql) can find and verify it + const newUser = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, [data.email], + ); + if (newUser.length) { + await this.dataSource.query( + `UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`, + [newUser[0].id], + ); + await this.dataSource.query( + `INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`, + [newUser[0].id, passwordHash], + ); + } + + // Assign the selected role in iam.user_roles + const rows = await this.dataSource.query( + `SELECT id, email, name, is_active, created_at FROM iam.users WHERE email = $1 LIMIT 1`, + [data.email], + ); + const u = rows[0]; + + if (data.role && u) { + try { + const roleRows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`, + [data.role], + ); + if (roleRows.length) { + await this.dataSource.query( + `INSERT INTO iam.user_roles (user_id, role_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [u.id, roleRows[0].id], + ); + } + } catch { + // non-fatal — role assignment failure should not block user creation + } + } + + return { + id: u.id, email: u.email, + fullName: data.fullName, role: data.role, + status: u.is_active ? 'ACTIVE' : 'INACTIVE', + createdAt: u.created_at, + }; + } + + async updateUser(id: string, data: { fullName?: string; role?: string; status?: string }) { + const rows = await this.dataSource.query( + `SELECT id, name, is_active FROM iam.users WHERE id = $1 LIMIT 1`, + [id], + ); + if (!rows.length) throw new ConflictException('User not found'); + const existing = rows[0]; + const name = data.fullName ? { en: data.fullName, am: data.fullName } : existing.name; + const isActive = data.status ? data.status === 'ACTIVE' : existing.is_active; + await this.dataSource.query( + `UPDATE iam.users SET name = $1::jsonb, is_active = $2, updated_at = NOW() WHERE id = $3`, + [JSON.stringify(name), isActive, id], + ); + + // Update role: remove existing user_roles then assign the new one + if (data.role) { + try { + const roleRows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.roles WHERE key = $1 LIMIT 1`, + [data.role], + ); + if (roleRows.length) { + await this.dataSource.query(`DELETE FROM iam.user_roles WHERE user_id = $1`, [id]); + await this.dataSource.query( + `INSERT INTO iam.user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [id, roleRows[0].id], + ); + } + } catch { + // non-fatal + } + } + + return { id, fullName: (name as any)?.en, role: data.role, status: isActive ? 'ACTIVE' : 'INACTIVE' }; + } + + async deleteUser(id: string) { + await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [id]); + return { success: true }; + } + + async resetUserPassword(id: string, tempPassword: string) { + const { hashPassword } = await import('@tria-plc/api-common/utils/argon'); + const passwordHash = await hashPassword(tempPassword); + // Deactivate existing credentials first (IAM keeps history, only one active at a time) + await this.dataSource.query( + `UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`, + [id], + ); + // Insert new active credential + await this.dataSource.query( + `INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`, + [id, passwordHash], + ); + return { success: true, message: 'Password reset successfully' }; + } + + private async compensateIamSignup(email: string): Promise { + try { + const rows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, + [email], + ); + if (!rows.length) return; + const iamUserId = rows[0].id; + + // Discover every table in the iam schema that has a FK pointing at iam.users.id + const fkDeps = await this.dataSource.query<{ table_name: string; column_name: string }[]>(` + SELECT kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + JOIN information_schema.key_column_usage ccu + ON rc.unique_constraint_name = ccu.constraint_name + WHERE ccu.table_schema = 'iam' AND ccu.table_name = 'users' AND ccu.column_name = 'id' + AND tc.table_schema = 'iam' AND tc.constraint_type = 'FOREIGN KEY' + `); + + for (const { table_name, column_name } of fkDeps) { + await this.dataSource.query( + `DELETE FROM iam.${table_name} WHERE ${column_name} = $1`, + [iamUserId], + ); + } + + await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [iamUserId]); + } catch (err) { + console.error('[PassengerAuthService] IAM compensating cleanup failed for', email, (err as Error).message); + } + } +} 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 285e38bf0..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,14 +1,15 @@ -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 { Throttle } from '@nestjs/throttler'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { IamGuard } from '../../common/iam-adapter'; @ApiTags('Booking') @Controller('bookings') +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class BookingsController { constructor( private service: BookingsService, @@ -45,7 +46,8 @@ export class BookingsController { } @Get('by-device') - @ApiOperation({ + @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.' }) @@ -73,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' }) @@ -99,7 +101,8 @@ export class BookingsController { } @Post('guest') - @ApiOperation({ + @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. @@ -247,12 +250,13 @@ export class BookingsController { }) @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' }) @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' }) - createGuest(@Body() dto: CreateGuestBookingDto) { - return this.guestService.createGuestBooking(dto); + createGuest(@Req() req: any, @Body() dto: CreateGuestBookingDto) { + return this.guestService.createGuestBooking(dto, req); } @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)' }) @@ -413,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' }) @@ -424,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.' }) @@ -448,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' }) @@ -459,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.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index bce07b915..63fd8be85 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -7,12 +7,13 @@ import { GuestBookingService } from './guest-booking.service'; import { SeatsModule } from '../seats/seats.module'; import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; +import { AuthModule } from '../auth/auth.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; -@Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule], - controllers: [BookingsController], - providers: [BookingsService, GuestBookingService], - exports: [BookingsService, GuestBookingService] +@Module({ + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule], + controllers: [BookingsController], + providers: [BookingsService, GuestBookingService], + exports: [BookingsService, GuestBookingService] }) export class BookingsModule {} 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 22c11b8f1..a81c6eeb0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -33,12 +35,13 @@ interface BookingFilters { @Injectable() export class BookingsService { constructor( - private prisma: PrismaService, - private seatsService: SeatsService, - private eventEmitter: EventEmitter2, - private verifaydaService: VerifaydaService, - private currencyService: CurrencyService, - private fareEngine: FareEngineService, + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly seatsService: SeatsService, + private readonly eventEmitter: EventEmitter2, + private readonly verifaydaService: VerifaydaService, + private readonly currencyService: CurrencyService, + private readonly fareEngine: FareEngineService, ) {} async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { @@ -111,22 +114,22 @@ export class BookingsService { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - // Find user with this device ID - const device = await this.prisma.device.findUnique({ - where: { id: deviceId }, - include: { user: { include: { passenger: true } } }, - }).catch(() => null); - + // Find passenger linked to this device via iamUserId + const device = await this.prisma.device.findUnique({ where: { id: deviceId } }).catch(() => null); + const passenger = device?.iamUserId + ? await this.prisma.passenger.findUnique({ where: { iamUserId: device.iamUserId } }).catch(() => null) + : null; + const searchConditions = search ? [ { bookingRef: { contains: search, mode: 'insensitive' } }, { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, ] : []; - + const where: any = { OR: [ { userAgent: deviceId }, - ...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []), + ...(passenger ? [{ passengerId: passenger.id }] : []), ], }; @@ -193,11 +196,27 @@ export class BookingsService { const where: any = {}; if (search) { + const iamRows = await this.dataSource.query<{ id: string }[]>( + `SELECT u.id FROM iam.users u + WHERE (u.name->>'en') ILIKE $1 OR (u.name->>'am') ILIKE $1 + OR u.email ILIKE $1 OR u.phone_number ILIKE $1`, + [`%${search}%`], + ); + const matchedPassengers = iamRows.length > 0 + ? await this.prisma.passenger.findMany({ + where: { iamUserId: { in: iamRows.map(r => r.id) } }, + select: { id: true }, + }) + : []; + where.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, { contactEmail: { contains: search, mode: 'insensitive' } }, { contactPhone: { contains: search, mode: 'insensitive' } }, - { passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } }, + ...(matchedPassengers.length > 0 + ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] + : []), + { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } @@ -211,7 +230,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - passenger: { include: { user: true } }, + passenger: { select: { id: true, iamUserId: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, @@ -219,33 +238,48 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + + const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + return { - items: items.map(booking => ({ - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.totalMinor, - currency: 'ETB', - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - contactEmail: booking.contactEmail, - contactPhone: booking.contactPhone, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - adultCount: booking.adultCount, - childCount: booking.childCount, - createdAt: booking.createdAt, - passenger: booking.passenger?.user, - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - })), + items: items.map(booking => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam + ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } + : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }), meta: { page, pageSize, @@ -262,10 +296,23 @@ export class BookingsService { return this.createOneWayBooking(dto); } + private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) { + for (const seatId of requestedSeatIds) { + if (!holdSeatIds.includes(seatId)) { + throw new BadRequestException( + `Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`, + ); + } + } + } + private async createOneWayBooking(dto: CreateBookingDto) { const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); - + + const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId); + this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds); + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } @@ -335,6 +382,11 @@ export class BookingsService { if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired'); if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired'); + const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean); + const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean); + if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds); + if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds); + const [outboundSchedule, returnSchedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, @@ -478,6 +530,11 @@ export class BookingsService { if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired'); if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired'); + const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId); + const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId); + this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds); + this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds); + const [leg1Schedule, leg2Schedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, @@ -624,6 +681,11 @@ export class BookingsService { if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired'); if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired'); + this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId)); + this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId)); + this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId)); + this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId)); + // Load all 4 schedules const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), @@ -815,7 +877,21 @@ export class BookingsService { nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + processedPassengers.push({ + ...passenger, + passengerName, + dateOfBirth, + category, + verifaydaVerified, + verifaydaData, + nationality, + // Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses + // outboundSeatId/returnSeatId. Accept either form so both DTOs work. + outboundSeatId: passenger.outboundSeatId ?? passenger.seatId, + outboundLeg2SeatId: passenger.outboundLeg2SeatId ?? passenger.leg2SeatId, + returnSeatId: passenger.returnSeatId, + returnLeg2SeatId: passenger.returnLeg2SeatId, + }); } return processedPassengers; } @@ -951,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 } } } } } } } }, @@ -1010,7 +1087,7 @@ export class BookingsService { await this.prisma.bookingModification.create({ data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason }, }); - await this.seatsService.releaseSeats(oldSeats); + await this.seatsService.releaseSeats(booking.id); await this.seatsService.confirmSeats(dto.newSeatIds); return { modified: true, bookingRef: dto.bookingRef }; } @@ -1021,7 +1098,7 @@ export class BookingsService { if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled'); const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0; await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); - await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(booking.id); await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } }); this.eventEmitter.emit('booking.cancelled', { booking, refundAmount }); return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; @@ -1050,7 +1127,7 @@ export class BookingsService { const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } }); if (!booking) throw new NotFoundException('Booking not found'); - await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(booking.id); await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } }); await this.prisma.booking.delete({ where: { id } }); @@ -1086,7 +1163,7 @@ export class BookingsService { const cutoff = new Date(Date.now() - 20 * 60 * 1000); const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } }); for (const b of expired) { - await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(b.id); await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); } } diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 922d7409b..f6971a1bc 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -3,17 +3,32 @@ import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; +import { PassengerAuthService } from '../auth/passenger-auth.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; -import * as bcrypt from 'bcrypt'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } +// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx) +const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964']; + +function generateEthiopianPhone(): string { + const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)]; + const suffix = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0'); + return `+251${prefix}${suffix}`; +} + +function generateGuestEmail(uniqueId: string): string { + const domains = ['gmail.com', 'yahoo.com', 'ethionet.et', 'telecom.et']; + const domain = domains[Math.floor(Math.random() * domains.length)]; + return `guest.edr.${uniqueId}@${domain}`; +} + function calculateAge(dateOfBirth: Date): number { const today = new Date(); let age = today.getFullYear() - dateOfBirth.getFullYear(); @@ -29,18 +44,19 @@ export class GuestBookingService { private seatsService: SeatsService, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, + private passengerAuthService: PassengerAuthService, private fareEngine: FareEngineService, private eventEmitter: EventEmitter2, ) {} - async createGuestBooking(dto: CreateGuestBookingDto) { - if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto); - if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto); - if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto); - return this.createGuestOneWayBooking(dto); + async createGuestBooking(dto: CreateGuestBookingDto, req?: any) { + if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req); + if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req); + if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req); + return this.createGuestOneWayBooking(dto, req); } - private async createGuestOneWayBooking(dto: CreateGuestBookingDto) { + private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) { // Validate hold const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) { @@ -80,15 +96,12 @@ export class GuestBookingService { let verifaydaData: Record | undefined; let nationality = passenger.nationality; - // Determine if passenger is Ethiopian - const isEthiopian = passenger.nationality === 'Ethiopian' || + const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; - - // Ethiopian with National ID + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (passenger.idDocumentNumber) { - // Attempt Fayda verification const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) { throw new BadRequestException( @@ -100,22 +113,14 @@ export class GuestBookingService { verifaydaData = verification.passengerData?.profileData; } nationality = 'Ethiopian'; - } - // International passenger with Passport (non-Ethiopian) - else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { - // Passport details are required for international passengers + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { if (!passenger.passportNumber || !passenger.passportCountry) { throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); } nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); - } - // Ethiopian with Passport (manual entry without Fayda) - else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { - // Ethiopians can use passport instead of national ID + } else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { nationality = 'Ethiopian'; - } - // International with National ID (e.g., Djiboutian national ID) - else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { nationality = nationality || 'Other'; } @@ -164,16 +169,27 @@ export class GuestBookingService { displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); } - // Create or get guest passenger + // Resolve or create the guest Passenger record const firstPassenger = passengersData[0]; - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req); // Save passenger details for future use (if requested) if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { for (const passenger of passengersData) { - // Note: SavedPassengerProfile will be available after migration - // Temporarily disabled until prisma generate completes - // await this.prisma.savedPassengerProfile.create({ ... }); + await this.prisma.savedPassengerProfile.create({ + data: { + userId: iamUserId ?? undefined, + deviceId: dto.deviceId, + passengerName: passenger.passengerName, + dateOfBirth: passenger.dateOfBirth, + idDocumentType: passenger.idDocumentType, + passportNumber: passenger.passportNumber, + passportCountry: passenger.passportCountry, + nationality: passenger.nationality, + phone: passenger.phone, + email: passenger.email, + }, + }); } } @@ -181,7 +197,7 @@ export class GuestBookingService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', totalMinor, @@ -222,7 +238,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { baseFareMinor, adultCount, @@ -242,7 +258,7 @@ export class GuestBookingService { }; } - private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) { + private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); } @@ -359,7 +375,7 @@ export class GuestBookingService { : totalMinor; // Create or resolve guest passenger (same as one-way) - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); // Create booking with outbound seats; return seats confirmed separately const outboundSeatIds = dto.passengers.map(p => p.seatId); @@ -368,7 +384,7 @@ export class GuestBookingService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', @@ -436,7 +452,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { outboundBaseFareMinor: outboundBaseFare, returnBaseFareMinor: returnBaseFare, @@ -455,7 +471,7 @@ export class GuestBookingService { }; } - private async createGuestTransitBooking(dto: CreateGuestBookingDto) { + private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); } @@ -556,13 +572,13 @@ export class GuestBookingService { ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', @@ -628,7 +644,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { leg1BaseFareMinor: leg1BaseFare, leg2BaseFareMinor: leg2BaseFare, @@ -642,7 +658,7 @@ export class GuestBookingService { }; } - private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) { + private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { @@ -749,7 +765,7 @@ export class GuestBookingService { ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; - const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ seat: { connect: { id: seatId } }, @@ -770,7 +786,7 @@ export class GuestBookingService { const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), - passengerId: guestPassenger.id, + passengerId: guestPassengerId, scheduleId: dto.scheduleId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', @@ -817,7 +833,7 @@ export class GuestBookingService { return { ...booking, createdAccount, - userId, + iamUserId, fareBreakdown: { outboundLeg1FareMinor: obL1Fare, outboundLeg2FareMinor: obL2Fare, @@ -836,62 +852,28 @@ export class GuestBookingService { private async resolveGuestPassenger( dto: Pick, firstPassenger: any, - ): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> { + req?: any, + ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { if (dto.createAccount && firstPassenger.email && dto.password) { - const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existingUser) throw new BadRequestException('Email already registered. Please login instead.'); - - let accountPhone = firstPassenger.phone || null; - if (accountPhone) { - const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } }); - if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.'); - } - if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - const user = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: firstPassenger.email, - phone: accountPhone, - passwordHash: await bcrypt.hash(dto.password, 10), - nationality: firstPassenger.nationality, - nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined, - passportNumber: firstPassenger.passportNumber, + const guestName = firstPassenger.passengerName ?? 'Guest'; + const result = await this.passengerAuthService.register( + { + email: firstPassenger.email, + username: firstPassenger.email, + phoneNumber: firstPassenger.phone || `+251900000000`, + name: { en: guestName, am: guestName }, + password: dto.password, + confirmPassword: dto.password, }, - }); - const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } }); - await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); - await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); - return { guestPassenger, userId: user.id, createdAccount: true }; + req, + ); + return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true }; } - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - let guestEmail = firstPassenger.email; - if (guestEmail) { - const existing = await this.prisma.user.findUnique({ where: { email: guestEmail } }); - if (existing) guestEmail = null; - } - if (!guestEmail) guestEmail = `guest-${uniqueId}@edr-platform.com`; - - let guestPhone = firstPassenger.phone; - if (guestPhone) { - const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); - if (existing) guestPhone = null; - } - if (!guestPhone) guestPhone = `+guest-${uniqueId}`; - - const tempUser = await this.prisma.user.create({ - data: { - fullName: firstPassenger.passengerName, - email: guestEmail, - phone: guestPhone, - passwordHash: await bcrypt.hash(Math.random().toString(36), 10), - role: 'PASSENGER', - }, - }); - const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } }); - return { guestPassenger, userId: null, createdAccount: false }; + const guestPassenger = await this.prisma.passenger.create({ data: {} }); + await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); + await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); + return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false }; } async getSavedPassengers(userId?: string, deviceId?: string): Promise { @@ -899,10 +881,6 @@ export class GuestBookingService { throw new BadRequestException('Either userId or deviceId is required'); } - // Temporarily return empty array until Prisma client is regenerated - return []; - - /* Uncomment after running migration and prisma generate const profiles = await this.prisma.savedPassengerProfile.findMany({ where: { OR: [ @@ -917,14 +895,13 @@ export class GuestBookingService { passengerName: p.passengerName, dateOfBirth: p.dateOfBirth.toISOString().split('T')[0], idDocumentType: p.idDocumentType, - idDocumentNumber: undefined, // Never return sensitive data + idDocumentNumber: undefined, passportNumber: p.passportNumber || undefined, passportCountry: p.passportCountry || undefined, nationality: p.nationality || undefined, phone: p.phone || undefined, email: p.email || undefined, })); - */ } private async getBaseFare( diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts index 3081c7a75..87dee3ec8 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { CurrenciesService } from './currencies.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Currencies') @Controller('currencies') @@ -15,8 +16,7 @@ export class CurrenciesController { } @Post() - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @HttpCode(201) createCurrency(@Body() dto: CreateCurrencyDto) { @@ -24,24 +24,21 @@ export class CurrenciesController { } @Patch(':id') - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) { return this.currenciesService.updateCurrency(id, dto); } @Delete(':id') - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerAdmin() @ApiBearerAuth('IAM-auth') deleteCurrency(@Param('id') id: string) { return this.currenciesService.deleteCurrency(id); } @Post('sync-rates') - @UseGuards(IamGuard) - @IamRoles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @HttpCode(200) syncRates() { diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index 1f9717c6a..2d52879a8 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -1,14 +1,19 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @Injectable() export class DashboardService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @InjectDataSource() private dataSource: DataSource, + ) {} async getHomeDashboard(passengerId: string) { const now = new Date(); const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ - this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }), + this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { loyalty: true } }), this.prisma.booking.findFirst({ where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } }, include: { @@ -27,7 +32,16 @@ export class DashboardService { const hour = now.getHours(); const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING'; - const firstName = passenger?.user.fullName.split(' ')[0] ?? ''; + + let firstName = ''; + if (passenger?.iamUserId) { + const iamRows = await this.dataSource.query<{ name: { en?: string; am?: string } | null }[]>( + `SELECT name FROM iam.users WHERE id = $1 LIMIT 1`, + [passenger.iamUserId], + ); + const name = iamRows[0]?.name; + firstName = (name?.en ?? name?.am ?? '').split(' ')[0]; + } const seat = upcomingBooking?.seats[0]; return { diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts new file mode 100644 index 000000000..6bbb190d0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -0,0 +1,80 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { + LogExcessBaggageDto, + WaiveChargeDto, + InitiateExcessPaymentDto, +} from './excess-baggage.dto'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +// ── IAM-protected agent/supervisor routes ──────────────────────────────────── +@ApiTags('Excess Baggage') +@Controller('agents/excess-baggage') +@UseGuards(IamJwtGuard) +@ApiBearerAuth('IAM-auth') +export class ExcessBaggageAgentController { + constructor(private service: ExcessBaggageService) {} + + @Post() + @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) + logCharge(@Body() dto: LogExcessBaggageDto) { + return this.service.logCharge(dto); + } + + @Get() + @ApiOperation({ summary: 'List all excess baggage charges (admin/supervisor)' }) + getAll( + @Query('status') status?: string, + @Query('bookingRef') bookingRef?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.getAll({ + status, + bookingRef, + page: page ? parseInt(page) : undefined, + pageSize: pageSize ? parseInt(pageSize) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) + getCharge(@Param('id') id: string) { + return this.service.getCharge(id); + } + + @Post(':id/resend') + @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) + resendLink(@Param('id') id: string) { + return this.service.resendLink(id); + } + + @Patch(':id/waive') + @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) + waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { + return this.service.waiveCharge(id, dto); + } +} + +// ── Public pay-by-token routes (passenger self-service) ────────────────────── +@ApiTags('Excess Baggage') +@Controller('excess-baggage') +export class ExcessBaggagePublicController { + constructor(private service: ExcessBaggageService) {} + + @Get('pay/:token') + @ApiOperation({ summary: 'Retrieve charge details by payment token (public)' }) + getByToken(@Param('token') token: string) { + return this.service.getByToken(token); + } + + @Post('pay/:token/initiate') + @ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' }) + initiatePayment( + @Param('token') token: string, + @Body() dto: InitiateExcessPaymentDto, + ) { + return this.service.initiatePayment(token, dto); + } +} diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts new file mode 100644 index 000000000..58bf2bf84 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsInt, IsOptional, IsPositive } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class LogExcessBaggageDto { + @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; + @ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string; + @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) + @IsInt() @IsPositive() excessWeightKg: number; + @ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' }) + @IsOptional() collectCash?: boolean; +} + +export class WaiveChargeDto { + @ApiProperty() @IsString() waivedBy: string; + @ApiPropertyOptional() @IsOptional() @IsString() waivedReason?: string; +} + +export class InitiateExcessPaymentDto { + @ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] }) + @IsString() method: string; + @ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string; +} diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts new file mode 100644 index 000000000..e0545d44f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { + ExcessBaggageAgentController, + ExcessBaggagePublicController, +} from './excess-baggage.controller'; +import { PaymentsModule } from '../payments/payments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [HttpModule, PaymentsModule, NotificationsModule], + controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController], + providers: [ExcessBaggageService], + exports: [ExcessBaggageService], +}) +export class ExcessBaggageModule {} diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts new file mode 100644 index 000000000..e868c0092 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -0,0 +1,252 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { PaymentClientService } from '../payments/payment-client.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { + LogExcessBaggageDto, + WaiveChargeDto, + InitiateExcessPaymentDto, +} from './excess-baggage.dto'; +import { + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + ProviderMethod, + ProviderPaymentStatus, +} from '@edr/types'; +import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; + +const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes + +@Injectable() +export class ExcessBaggageService { + private readonly logger = new Logger(ExcessBaggageService.name); + + constructor( + private prisma: PrismaService, + private paymentClient: PaymentClientService, + private notifications: NotificationsService, + ) {} + + async logCharge(dto: LogExcessBaggageDto) { + const booking = await this.prisma.booking.findUnique({ + where: { id: dto.bookingId }, + include: { + seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + passenger: { include: { user: true } }, + }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { + throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage'); + } + + // Resolve fee per kg from BaggageAllowance via seat class + const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId; + let feePerKgMinor = 5000; // 50 ETB default fallback (in minor) + if (coachTypeId) { + const seatClass = await this.prisma.seatClass.findFirst({ + where: { coachTypeId }, + }); + if (seatClass) { + const allowance = await this.prisma.baggageAllowance.findFirst({ + where: { seatClassId: seatClass.id }, + }); + if (allowance) feePerKgMinor = allowance.excessFeePerKg; + } + } + + const totalMinor = feePerKgMinor * dto.excessWeightKg; + const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); + const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; + const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + + const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING'; + const paidAt = dto.collectCash ? new Date() : null; + + const charge = await this.prisma.excessBaggageCharge.create({ + data: { + bookingId: dto.bookingId, + agentId: dto.agentId, + excessWeightKg: dto.excessWeightKg, + feePerKgMinor, + totalMinor, + status, + expiresAt, + paidAt, + contactPhone, + contactEmail, + }, + }); + + if (!dto.collectCash) { + await this.sendPaymentLink(charge, booking, contactPhone, contactEmail); + } + + return charge; + } + + private async sendPaymentLink( + charge: any, + booking: any, + phone: string | null, + email: string | null, + ) { + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const payUrl = `${portalUrl}/excess-baggage/pay/${charge.paymentToken}`; + const amountStr = (charge.totalMinor / 100).toFixed(2); + const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`; + + const recipient = phone ?? email ?? booking.passengerId; + try { + await this.notifications['deliverSms'](recipient, msg); + } catch (err) { + this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`); + } + if (email) { + try { + await this.notifications['deliverEmail']( + recipient, + `EDR — Excess baggage payment required (${booking.bookingRef})`, + msg, + ); + } catch (err) { + this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`); + } + } + } + + async getCharge(id: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id }, + include: { booking: { select: { bookingRef: true, status: true } } }, + }); + if (!charge) throw new NotFoundException('Charge not found'); + return charge; + } + + async getByToken(token: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + include: { booking: { select: { bookingRef: true, scheduleId: true } } }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'EXPIRED' || new Date() > charge.expiresAt) { + if (charge.status === 'PENDING') { + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { status: 'EXPIRED' }, + }); + } + throw new BadRequestException('This payment link has expired'); + } + if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') { + throw new BadRequestException('This charge has already been paid'); + } + if (charge.status === 'WAIVED') { + throw new BadRequestException('This charge has been waived'); + } + return charge; + } + + async initiatePayment(token: string, dto: InitiateExcessPaymentDto) { + const charge = await this.getByToken(token); + + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`; + + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType, + referenceId: charge.id, + orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`, + amountMinor: charge.totalMinor / 100, + currency: charge.currency, + provider: dto.method as unknown as ProviderMethod, + platform: dto.platform as any, + returnUrl, + failureUrl: returnUrl, + }); + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markPaid(charge.id, snapshot.providerTxnId); + } + + return { + chargeId: charge.id, + status: snapshot.status, + clientAction: snapshot.clientAction, + merchantOrderId: snapshot.merchantOrderId, + }; + } + + async markPaid(chargeId: string, providerTxnId?: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') return charge; + return this.prisma.excessBaggageCharge.update({ + where: { id: chargeId }, + data: { status: 'PAID', paidAt: new Date() }, + }); + } + + async waiveCharge(id: string, dto: WaiveChargeDto) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) { + throw new BadRequestException('Cannot waive a charge that has already been paid'); + } + return this.prisma.excessBaggageCharge.update({ + where: { id }, + data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason }, + }); + } + + async resendLink(id: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id }, + include: { booking: { select: { bookingRef: true, passengerId: true } } }, + }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status !== 'PENDING') { + throw new BadRequestException('Can only resend link for PENDING charges'); + } + // Extend expiry by 30 minutes from now + const updatedCharge = await this.prisma.excessBaggageCharge.update({ + where: { id }, + data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, + }); + await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail); + return { sent: true }; + } + + async getAll(filters: { + status?: string; + bookingRef?: string; + page?: number; + pageSize?: number; + }) { + const { status, bookingRef, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + const where: any = {}; + if (status) where.status = status; + if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } }; + + const [items, total] = await Promise.all([ + this.prisma.excessBaggageCharge.findMany({ + where, + include: { booking: { select: { bookingRef: true, status: true } } }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.excessBaggageCharge.count({ where }), + ]); + + return { items, total, page, pageSize }; + } +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 60a6ef11d..45c600c67 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger'; import { FleetService } from './fleet.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Fleet') @@ -317,6 +317,39 @@ export class FleetController { return this.service.removeAssignment(id); } + @Post('seatmap/generate') + @ApiOperation({ + summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED', + description: `Generates a structured seat map for bed coaches without persisting anything. + +**ECONOMY_BED**: 6 beds per room — Left(Lower/Middle/Upper) + Right(Lower/Middle/Upper) + +**VIP_BED**: 4 beds per room — Left(Lower/Upper) + Right(Lower/Upper) + +Use this to preview the full flat seat list before creating coaches.`, + }) + @ApiBody({ type: GenerateSeatMapDto }) + @ApiResponse({ + status: 201, + description: 'Generated seat map preview', + schema: { + example: { + coachCount: 1, roomsPerCoach: 2, roomType: 'ECONOMY_BED', bedsPerRoom: 6, totalBeds: 12, + seats: [ + { seat_id: 'C1-C1-R1-S1', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'LOWER', sequence_number: 1, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S2', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'MIDDLE', sequence_number: 2, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S3', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'UPPER', sequence_number: 3, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S4', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'LOWER', sequence_number: 4, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S5', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'MIDDLE', sequence_number: 5, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S6', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'UPPER', sequence_number: 6, status: 'AVAILABLE' }, + ], + }, + }, + }) + generateSeatMap(@Body() dto: GenerateSeatMapDto) { + return this.service.generateSeatMapPreview(dto); + } + @Get('analytics') @ApiOperation({ summary: 'Fleet analytics and occupancy metrics' }) @ApiResponse({ status: 200, description: 'Occupancy statistics' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 609afb4e5..34579c627 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -12,9 +12,20 @@ export class CreateTrainDto { export class CreateCoachDto { @ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string; @ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string; - @ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string; - @ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number; + @ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string; + @ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number; @ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string; + @ApiPropertyOptional({ + enum: ['ECONOMY_BED', 'VIP_BED'], + description: 'Bed coach category. Set to generate bed/sleeper compartments instead of regular seats. Overrides name-based detection.', + example: 'VIP_BED', + }) + @IsOptional() @IsString() bedCategory?: 'ECONOMY_BED' | 'VIP_BED'; + @ApiPropertyOptional({ + example: 4, + description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.', + }) + @IsOptional() @IsInt() bedsPerRoom?: number; } export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) { @@ -67,3 +78,14 @@ export class UpdateClassDto { @IsBoolean() isActive?: boolean; } + +export class GenerateSeatMapDto { + @ApiProperty({ example: 2, description: 'Number of coaches' }) + @IsInt() coachCount: number; + + @ApiProperty({ example: 9, description: 'Number of rooms (compartments) per coach' }) + @IsInt() roomsPerCoach: number; + + @ApiProperty({ enum: ['ECONOMY_BED', 'VIP_BED'], example: 'ECONOMY_BED', description: 'ECONOMY_BED = 6 beds/room (L/M/U × Left/Right), VIP_BED = 4 beds/room (L/U × Left/Right)' }) + @IsString() roomType: 'ECONOMY_BED' | 'VIP_BED'; +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index b7f4695a1..09a1b4ed3 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { SeatKind } from '@prisma/client'; // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] @@ -33,41 +33,97 @@ function isAisleCol(colIndex: number, groups: number[]): boolean { return false; } -function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] { +type BedCategory = 'ECONOMY_BED' | 'VIP_BED' | null; + +// Default beds per room for each category when not explicitly configured +const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = { + VIP_BED: 4, + ECONOMY_BED: 6, +}; + +// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type +function detectBedCategory(coachTypeName: string): BedCategory { + const name = coachTypeName.toLowerCase(); + const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette'); + if (!isBed) return null; + if (name.includes('vip')) return 'VIP_BED'; + return 'ECONOMY_BED'; +} + +// Resolves bed type names per side from beds-per-side count: +// 2/side → ['LOWER','UPPER'] (VIP style) +// 3/side → ['LOWER','MIDDLE','UPPER'] (Economy style) +function resolveBedTypes(bedsPerSide: number): string[] { + if (bedsPerSide === 1) return ['LOWER']; + if (bedsPerSide === 2) return ['LOWER', 'UPPER']; + if (bedsPerSide === 3) return ['LOWER', 'MIDDLE', 'UPPER']; + return Array.from({ length: bedsPerSide }, (_, i) => { + if (i === 0) return 'LOWER'; + if (i === bedsPerSide - 1) return 'UPPER'; + return 'MIDDLE'; + }); +} + +// Generates the flat seat/bed list for a bed coach. +// Row = room number; col = position-relative label (L1, L2 … R1, R2 …). +function buildBedSeats( + coachId: string, + capacity: number, + bedsPerRoom: number, +): SeatRow[] { + const bedsPerSide = bedsPerRoom / 2; + const bedTypeNames = resolveBedTypes(bedsPerSide); + const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ + ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), + ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), + ]; + + const roomCount = Math.ceil(capacity / bedsPerRoom); + const seats: SeatRow[] = []; + let seatNumber = 1; + + for (let room = 1; room <= roomCount; room++) { + const posCount: Record = {}; + for (let slot = 0; slot < bedsPerRoom && seats.length < capacity; slot++) { + const { position, bedType } = layout[slot]; + posCount[position] = (posCount[position] ?? 0) + 1; + const col = `${position[0]}${posCount[position]}`; + seats.push({ + coachId, + row: room, + col, + seatNumber: `${seatNumber}`, + kind: SeatKind.STANDARD, + bedPosition: bedType.toLowerCase(), + isWindow: false, + isAisle: false, + }); + seatNumber++; + } + } + return seats; +} + +function buildRegularSeats(coachId: string, arrangement: string, capacity: number): SeatRow[] { const cols = seatCols(arrangement); const groups = parseArrangement(arrangement); const seats: SeatRow[] = []; let row = 1; let seatNumber = 1; let seatIndex = 0; - const isBedCoach = seatClass?.toLowerCase().includes('bed'); - const totalCols = cols.length; while (seatIndex < capacity) { for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) { const col = cols[ci]; - let bedPosition = null; - - // Set bedPosition for bed coaches based on ROW cycling (not seat number) - if (isBedCoach) { - if (totalCols === 3) { - // Economy bed (3-row cycle): upper, middle, lower - if (row % 3 === 1) bedPosition = 'upper'; - else if (row % 3 === 2) bedPosition = 'middle'; - else bedPosition = 'lower'; - } else if (totalCols === 2) { - // VIP bed (2-row cycle): upper, lower - bedPosition = row % 2 === 1 ? 'upper' : 'lower'; - } - } - seats.push({ coachId, row, col, seatNumber: `${seatNumber}`, kind: SeatKind.STANDARD, - bedPosition, + bedPosition: null, + isWindow: isWindowCol(ci, groups), + isAisle: isAisleCol(ci, groups), }); seatNumber++; seatIndex++; @@ -84,6 +140,8 @@ type SeatRow = { seatNumber: string; kind: SeatKind; bedPosition?: string | null; + isWindow?: boolean; + isAisle?: boolean; }; @Injectable() @@ -332,8 +390,20 @@ export class FleetService { }); if (dto.capacity > 0) { - const seatClass = coach.coachType?.name || ''; - const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass); + // dto.bedCategory takes priority; fall back to name-based detection + const bedCategory: BedCategory = dto.bedCategory ?? detectBedCategory(coach.coachType?.name || ''); + let seats: SeatRow[]; + + if (bedCategory) { + const bedsPerRoom = dto.bedsPerRoom ?? DEFAULT_BEDS_PER_ROOM[bedCategory]; + if (bedsPerRoom < 2 || bedsPerRoom % 2 !== 0) { + throw new BadRequestException('bedsPerRoom must be an even number ≥ 2'); + } + seats = buildBedSeats(coach.id, dto.capacity, bedsPerRoom); + } else { + seats = buildRegularSeats(coach.id, dto.arrangement, dto.capacity); + } + await this.prisma.seat.createMany({ data: seats }); } @@ -425,6 +495,50 @@ export class FleetService { return this.prisma.coachAssignment.delete({ where: { id } }); } + async generateSeatMapPreview(dto: GenerateSeatMapDto) { + const { coachCount, roomsPerCoach, roomType } = dto; + const bedsPerRoom = DEFAULT_BEDS_PER_ROOM[roomType]; + const bedTypeNames = resolveBedTypes(bedsPerRoom / 2); + const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ + ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), + ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), + ]; + const seats: object[] = []; + let globalSeq = 1; + + for (let c = 1; c <= coachCount; c++) { + const coachLabel = `C${c}`; + for (let r = 1; r <= roomsPerCoach; r++) { + const roomLabel = `R${r}`; + const posCount: Record = {}; + for (let s = 0; s < bedsPerRoom; s++) { + const { position, bedType } = layout[s]; + posCount[position] = (posCount[position] ?? 0) + 1; + seats.push({ + seat_id: `${coachLabel}-${roomLabel}-S${globalSeq}`, + coach_id: coachLabel, + room_id: `${coachLabel}-${roomLabel}`, + category: roomType, + position, + col: `${position[0]}${posCount[position]}`, + bed_type: bedType, + sequence_number: globalSeq, + status: 'AVAILABLE', + }); + globalSeq++; + } + } + } + return { + coachCount, + roomsPerCoach, + roomType, + bedsPerRoom, + totalBeds: seats.length, + seats, + }; + } + async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index 4056b4259..c53d3a5fb 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,12 +1,12 @@ -import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; -import { UserRole } from '@prisma/client'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Fraud Detection') @Controller('fraud') -@UseGuards(IamGuard) +@PassengerStaff([PASSENGER_PERMS.fraud.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class FraudController { private readonly logger = new Logger(FraudController.name); @@ -17,7 +17,6 @@ export class FraudController { * Get fraud alerts */ @Get('alerts') - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'Get fraud alerts' }) async getAlerts( @Query('userId') userId?: string, @@ -32,7 +31,6 @@ export class FraudController { * Get fraud rules */ @Get('rules') - @IamRoles('ADMIN') @ApiOperation({ summary: 'Get fraud detection rules' }) async getRules() { const rules = await this.fraudService.getRules(); @@ -43,7 +41,7 @@ export class FraudController { * Create or update fraud rule */ @Post('rules') - @IamRoles('ADMIN') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Create or update fraud rule' }) async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { const rule = await this.fraudService.upsertRule(body.type, body.config); @@ -54,10 +52,10 @@ export class FraudController { * Block user temporarily */ @Post('actions/block') - @IamRoles('ADMIN', 'SUPERVISOR') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Block user temporarily' }) - async blockUser(@Body() body: { userId: string; durationMinutes: number }) { - await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes); + async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) { + await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes); return { message: `User blocked for ${body.durationMinutes} minutes` }; } @@ -65,10 +63,10 @@ export class FraudController { * Unblock user */ @Post('actions/unblock') - @IamRoles('ADMIN', 'SUPERVISOR') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Unblock user' }) - async unblockUser(@Body() body: { userId: string }) { - await this.fraudService.unblockUser(body.userId); + async unblockUser(@Body() body: { iamUserId: string }) { + await this.fraudService.unblockUser(body.iamUserId); return { message: 'User unblocked' }; } } diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts index 7c3b66e6b..a75db4449 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; export interface FraudRuleConfig { @@ -14,47 +16,37 @@ export interface FraudRuleConfig { export class FraudService { private readonly logger = new Logger(FraudService.name); - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @InjectDataSource() private dataSource: DataSource, + ) {} /** * Evaluate fraud rules and create alerts if triggered */ async evaluateRules( - userId: string, + passengerId: string, eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed', context: Record, ): Promise<{ triggered: boolean; rules: string[] }> { const triggeredRules: string[] = []; - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user) return { triggered: false, rules: [] }; - - // Check velocity rule (multiple bookings in short time) if (eventType === 'booking.created') { - const velocityTriggered = await this.checkVelocityRule(userId); - if (velocityTriggered) { - triggeredRules.push('VELOCITY'); - } + const velocityTriggered = await this.checkVelocityRule(passengerId); + if (velocityTriggered) triggeredRules.push('VELOCITY'); - // Check high-value booking const amount = (context.amountMinor as number) || 0; const highValueTriggered = await this.checkHighValueRule(amount); - if (highValueTriggered) { - triggeredRules.push('HIGH_VALUE'); - } + if (highValueTriggered) triggeredRules.push('HIGH_VALUE'); } - // Check repeated failed payments if (eventType === 'payment.failed') { - const failedPaymentTriggered = await this.checkFailedPaymentRule(userId); - if (failedPaymentTriggered) { - triggeredRules.push('FAILED_PAYMENTS'); - } + const failedPaymentTriggered = await this.checkFailedPaymentRule(passengerId); + if (failedPaymentTriggered) triggeredRules.push('FAILED_PAYMENTS'); } - // Create alert if rules triggered if (triggeredRules.length > 0) { - await this.createFraudAlert(userId, eventType, triggeredRules, context); + await this.createFraudAlert(passengerId, eventType, triggeredRules, context); return { triggered: true, rules: triggeredRules }; } @@ -64,7 +56,7 @@ export class FraudService { /** * Check velocity rule: X bookings in Y minutes */ - private async checkVelocityRule(userId: string): Promise { + private async checkVelocityRule(passengerId: string): Promise { const rule = await this.prisma.fraudRule.findFirst({ where: { type: 'VELOCITY', enabled: true }, }); @@ -72,18 +64,14 @@ export class FraudService { if (!rule) return false; const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30; - const threshold = rule.threshold; - const bookingCount = await this.prisma.booking.count({ where: { - passengerId: userId, - createdAt: { - gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000), - }, + passengerId, + createdAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) }, }, }); - return bookingCount > threshold; + return bookingCount > rule.threshold; } /** @@ -104,7 +92,7 @@ export class FraudService { /** * Check failed payment rule: X failed attempts in Y minutes */ - private async checkFailedPaymentRule(userId: string): Promise { + private async checkFailedPaymentRule(passengerId: string): Promise { const rule = await this.prisma.fraudRule.findFirst({ where: { type: 'FAILED_PAYMENTS', enabled: true }, }); @@ -112,33 +100,33 @@ export class FraudService { if (!rule) return false; const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60; - const threshold = rule.threshold; - const failedCount = await this.prisma.paymentIntent.count({ where: { - booking: { passengerId: userId }, + booking: { passengerId }, status: 'FAILED', - updatedAt: { - gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000), - }, + updatedAt: { gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000) }, }, }); - return failedCount > threshold; + return failedCount > rule.threshold; } /** * Create a fraud alert */ private async createFraudAlert( - userId: string, + passengerId: string, eventType: string, triggeredRules: string[], context: Record, ): Promise { + const passenger = await this.prisma.passenger.findUnique({ + where: { id: passengerId }, + select: { iamUserId: true }, + }); const alert = await this.prisma.fraudAlert.create({ data: { - userId, + iamUserId: passenger?.iamUserId ?? passengerId, eventType, triggeredRules, context: context as any, @@ -146,35 +134,34 @@ export class FraudService { }, }); - this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`); + this.logger.warn(`Fraud alert created: ${alert.id} for passenger ${passengerId} - rules: ${triggeredRules.join(', ')}`); - // Trigger blocking if needed if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) { - await this.blockUserTemporarily(userId, 30); // Block for 30 minutes + if (passenger?.iamUserId) await this.blockUserTemporarily(passenger.iamUserId, 30); } } /** * Block user temporarily */ - async blockUserTemporarily(userId: string, durationMinutes: number): Promise { + async blockUserTemporarily(iamUserId: string, durationMinutes: number): Promise { const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000); - await this.prisma.user.update({ - where: { id: userId }, + await this.prisma.passenger.updateMany({ + where: { iamUserId }, data: { blockedUntil }, }); - this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`); + this.logger.warn(`Passenger (iamUserId=${iamUserId}) blocked until ${blockedUntil.toISOString()}`); } /** * Unblock user */ - async unblockUser(userId: string): Promise { - await this.prisma.user.update({ - where: { id: userId }, + async unblockUser(iamUserId: string): Promise { + await this.prisma.passenger.updateMany({ + where: { iamUserId }, data: { blockedUntil: null }, }); - this.logger.log(`User ${userId} unblocked`); + this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`); } /** @@ -182,7 +169,7 @@ export class FraudService { */ async getAlerts(userId?: string, limit = 100, offset = 0) { return this.prisma.fraudAlert.findMany({ - where: userId ? { userId } : {}, + where: userId ? { iamUserId: userId } : {}, orderBy: { createdAt: 'desc' }, take: limit, skip: offset, @@ -234,9 +221,10 @@ export class FraudService { * Event listener for payment failed */ @OnEvent('payment.failed') - async onPaymentFailed(payload: { intentId: string; userId: string }) { - await this.evaluateRules(payload.userId, 'payment.failed', { - intentId: payload.intentId, + async onPaymentFailed(payload: { booking: { passengerId: string; id: string } }) { + if (!payload.booking?.passengerId) return; + await this.evaluateRules(payload.booking.passengerId, 'payment.failed', { + bookingId: payload.booking.id, }); } @@ -244,9 +232,18 @@ export class FraudService { * Event listener for auth login failed */ @OnEvent('auth.login.failed') - async onLoginFailed(payload: { userId: string; email: string }) { - await this.evaluateRules(payload.userId, 'auth.login.failed', { - email: payload.email, + async onLoginFailed(payload: { email: string }) { + if (!payload.email) return; + const iamRows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 LIMIT 1`, + [payload.email], + ); + if (!iamRows.length) return; + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: iamRows[0].id }, + select: { id: true }, }); + if (!passenger) return; + await this.evaluateRules(passenger.id, 'auth.login.failed', { email: payload.email }); } } diff --git a/apps/edr-passenger-api/src/modules/health/health.controller.ts b/apps/edr-passenger-api/src/modules/health/health.controller.ts new file mode 100644 index 000000000..6cc50e24e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/health/health.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { PrismaService } from '../../common/prisma.service'; + +@ApiTags('Health') +@Controller('health') +@SkipThrottle() +export class HealthController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + @IsPublic() + @ApiOperation({ summary: 'Liveness probe' }) + liveness() { + return { status: 'ok', timestamp: new Date().toISOString() }; + } + + @Get('ready') + @IsPublic() + @ApiOperation({ summary: 'Readiness probe — checks database connectivity' }) + async readiness() { + const start = Date.now(); + try { + await this.prisma.$queryRaw`SELECT 1`; + return { + status: 'ok', + timestamp: new Date().toISOString(), + checks: { database: { status: 'ok', latencyMs: Date.now() - start } }, + }; + } catch (err) { + return { + status: 'error', + timestamp: new Date().toISOString(), + checks: { + database: { + status: 'error', + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : 'Unknown error', + }, + }, + }; + } + } + + @Get('info') + @IsPublic() + @ApiOperation({ summary: 'App info — version, environment, uptime' }) + info() { + return { + name: 'edr-passenger-api', + version: process.env.npm_package_version ?? '1.0.0', + environment: process.env.NODE_ENV ?? 'development', + uptimeSeconds: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/edr-passenger-api/src/modules/health/health.module.ts b/apps/edr-passenger-api/src/modules/health/health.module.ts new file mode 100644 index 000000000..375e4d84d --- /dev/null +++ b/apps/edr-passenger-api/src/modules/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index b622e3b7d..f27bd8ecd 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -2,7 +2,8 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/co import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { TestNotificationDto } from './notifications.dto'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; @@ -39,8 +40,7 @@ export class NotificationsController { } @Post('send/email') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send a direct email via the email microservice' }) @ApiBody({ type: SendEmail }) sendEmail(@Body() dto: SendEmail) { @@ -48,8 +48,7 @@ export class NotificationsController { } @Post('send/sms') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' }) @ApiBody({ type: SingleMessageDto }) sendSms(@Body() dto: SingleMessageDto) { @@ -57,8 +56,7 @@ export class NotificationsController { } @Post('send/sms/bulk') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' }) @ApiBody({ type: BulkMessagesDto }) sendBulkSms(@Body() dto: BulkMessagesDto) { @@ -66,8 +64,6 @@ export class NotificationsController { } @Post('test') - @UseGuards(IamGuard) - @IamRoles('ADMIN', 'STAFF') @ApiOperation({ summary: 'Test notification delivery (Admin only)' }) async testNotification(@Body() dto: TestNotificationDto) { return this.service.send( diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 0c6add4b2..c7ed031cd 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; @@ -7,6 +9,8 @@ import { SmsClientService } from './sms-client.service'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); @@ -14,6 +18,7 @@ export class NotificationsService { constructor( private prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, private emailClient: EmailClientService, private smsClient: SmsClientService, private pushAdapter: PushAdapter, @@ -112,22 +117,20 @@ export class NotificationsService { body: string, context: Record, ): Promise { - // Try to find passenger by ID or email let passengerId = recipient; - if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) { - const user = await this.prisma.user.findFirst({ - where: { - OR: [{ email: recipient }, { phone: recipient }], - }, - include: { passenger: true }, - }); - if (user?.passenger) { - passengerId = user.passenger.id; - } else { + if (!UUID_RE.test(recipient)) { + const iamUserId = await this.resolveIamUserId(recipient); + if (!iamUserId) { this.logger.warn(`Could not find passenger for recipient: ${recipient}`); return; } + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId } }); + if (!passenger) { + this.logger.warn(`Could not find passenger for recipient: ${recipient}`); + return; + } + passengerId = passenger.id; } await this.prisma.notification.create({ @@ -163,26 +166,19 @@ export class NotificationsService { } private async getUserPreferredChannels(recipient: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - OR: [ - { id: recipient }, - { email: recipient }, - { phone: recipient }, - { passenger: { id: recipient } }, - ], - }, - include: { preferences: true }, - }); + const iamUserId = await this.resolveIamUserId(recipient); + const preferences = iamUserId + ? await this.prisma.userPreferences.findUnique({ where: { iamUserId } }) + : null; - if (!user?.preferences) { + if (!preferences) { return ['IN_APP', 'EMAIL']; } const channels: NotificationChannelType[] = ['IN_APP']; - if (user.preferences.emailEnabled) channels.push('EMAIL'); - if (user.preferences.smsEnabled) channels.push('SMS'); - if (user.preferences.pushEnabled) channels.push('PUSH'); + if (preferences.emailEnabled) channels.push('EMAIL'); + if (preferences.smsEnabled) channels.push('SMS'); + if (preferences.pushEnabled) channels.push('PUSH'); return channels; } @@ -191,32 +187,44 @@ export class NotificationsService { recipient: string, channel: NotificationChannelType, ): Promise { - const user = await this.prisma.user.findFirst({ - where: { - OR: [ - { id: recipient }, - { email: recipient }, - { phone: recipient }, - { passenger: { id: recipient } }, - ], - }, - }); - - if (!user) return null; + const iamUserId = await this.resolveIamUserId(recipient); + if (!iamUserId) return null; + const contact = await this.resolveContactInfo(iamUserId); switch (channel) { - case 'EMAIL': - return user.email; - case 'SMS': - return user.phone; - case 'PUSH': - // Would need to fetch device push token - return user.id; - default: - return null; + case 'EMAIL': return contact.email; + case 'SMS': return contact.phone; + case 'PUSH': return iamUserId; + default: return null; } } + private async resolveIamUserId(recipient: string): Promise { + if (UUID_RE.test(recipient)) { + const passenger = await this.prisma.passenger.findUnique({ where: { id: recipient } }); + return passenger?.iamUserId ?? recipient; + } + const rows = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`, + [recipient], + ); + return rows[0]?.id ?? null; + } + + private async resolveContactInfo(iamUserId: string): Promise<{ email: string | null; phone: string | null }> { + const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>( + `SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + return { email: rows[0]?.email ?? null, phone: rows[0]?.phone_number ?? null }; + } + + private sanitize(value: string): string { + return value + .replace(/[\r\n]/g, ' ') + .replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c)); + } + getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, @@ -431,6 +439,126 @@ export class NotificationsService { `; } + async sendBoardingPassNotification(params: { + passengerId: string | null; + contactEmail: string | null; + contactPhone: string | null; + bookingRef: string; + leg: string | null; + booking: any; + ticket: any; + }): Promise { + const { passengerId, contactEmail, contactPhone, bookingRef, leg, booking, ticket } = params; + + // Resolve contact — prefer IAM user record, fall back to booking contact fields + let email: string | null = contactEmail ?? null; + let phone: string | null = contactPhone ?? null; + if (passengerId) { + const resolved = await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null); + const resolvedPhone = await this.getRecipientAddress(passengerId, 'SMS').catch(() => null); + if (resolved) email = resolved; + if (resolvedPhone) phone = resolvedPhone; + } + + const s = booking.schedule ?? {}; + const fmt = (d: any) => + d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; + const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : ''; + const origin = s.originStation?.name ?? ''; + const dest = s.destinationStation?.name ?? ''; + const train = s.train?.name ?? s.train?.number ?? ''; + const dep = fmt(s.departureAt); + const arr = fmt(s.arrivalAt); + + const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({ + name: bs.passengerName ?? '', + coach: bs.seat?.coach?.number ?? '-', + seat: bs.seat?.seatNumber ?? '-', + cls: bs.seat?.coach?.coachType?.name ?? '-', + })); + + const seatLines = seats.map(s => ` ${s.name} — Coach ${s.coach}, Seat ${s.seat} (${s.cls})`).join('\n'); + + const smsText = + `EDR Boarding Pass${legLabel}\n` + + `Ref: ${bookingRef}\n` + + `${origin} → ${dest}\n` + + `Train: ${train} | Dep: ${dep}\n` + + (seatLines ? `${seatLines}\n` : '') + + `Barcode: ${ticket.barcodePayload}`; + + if (phone) { + await this.smsClient.sendSms({ to: phone, message: smsText }).catch((e) => + this.logger.error(`Boarding pass SMS failed for ${bookingRef}: ${e?.message}`), + ); + } + + if (email) { + const seatRows = seats + .map( + (s) => + ` + ${s.name} + ${s.coach} + ${s.seat} + ${s.cls} + `, + ) + .join(''); + + const html = ` + + + +
+
+

Ethio-Djibouti Railway

+

Boarding Pass${legLabel}

+
+
+

Booking reference: ${bookingRef}

+ + + + + + +
From${origin}
To${dest}
Train${train}
Departs${dep}
Arrives${arr}
+

Passengers

+ + + + + + + + ${seatRows} +
NameCoachSeatClass
+
+

QR code for gate scanning

+ Boarding pass QR +

Barcode: ${ticket.barcodePayload}

+
+
+
+

© Ethio-Djibouti Railway. All rights reserved.

+
+
+ +`; + + const textFallback = + `EDR Boarding Pass${legLabel}\nRef: ${bookingRef}\n${origin} → ${dest}\n` + + `Train: ${train} | Departs: ${dep} | Arrives: ${arr}\n${seatLines}\n` + + `Barcode: ${ticket.barcodePayload}`; + + await this.emailClient + .sendEmail({ to: email, subject: `EDR Boarding Pass — ${bookingRef}${legLabel}`, text: textFallback, html }) + .catch((e) => this.logger.error(`Boarding pass email failed for ${bookingRef}: ${e?.message}`)); + } + } + @OnEvent('payment.failed') async onPaymentFailed(payload: any) { const booking = payload.booking; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts new file mode 100644 index 000000000..9da8a759b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -0,0 +1,107 @@ +import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { PackagesService } from './packages.service'; +import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto'; +import { JwtGuard } from '../../common/jwt.guard'; +import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; + +@ApiTags('Packages') +@Controller('packages') +export class PackagesController { + constructor(private readonly service: PackagesService) {} + + @Get() + @IsPublic() + @ApiOperation({ summary: 'List active packages' }) + listActive() { + return this.service.listActive(); + } + + @Get('all') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List all packages (admin)' }) + listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); + } + + @Get('my-bookings') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get my package bookings' }) + myBookings(@Request() req: any) { + return this.service.getMyBookings(req.user.passengerId); + } + + @Get('booking/:ref') + @IsPublic() + @ApiOperation({ summary: 'Get package booking by reference' }) + getBookingByRef(@Param('ref') ref: string) { + return this.service.getBookingByRef(ref); + } + + @Get(':id') + @IsPublic() + @ApiOperation({ summary: 'Get package details' }) + getById(@Param('id') id: string) { + return this.service.getById(id); + } + + @Post() + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create package (admin)' }) + create(@Body() dto: CreatePackageDto) { + return this.service.create(dto); + } + + @Patch(':id') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update package (admin)' }) + update(@Param('id') id: string, @Body() dto: Partial) { + return this.service.update(id, dto); + } + + @Patch(':id/activate') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Activate package (admin)' }) + activate(@Param('id') id: string) { + return this.service.activate(id); + } + + @Post(':id/tiers') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Add price tier to package (admin)' }) + addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) { + return this.service.addTier(id, dto); + } + + @Patch('tiers/:tierId') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update price tier (admin)' }) + updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) { + return this.service.updateTier(tierId, dto); + } + + @Delete('tiers/:tierId') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete price tier (admin)' }) + deleteTier(@Param('tierId') tierId: string) { + return this.service.deleteTier(tierId); + } + + @Post('book') + @IsPublic() + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Book a package (public or authenticated)' }) + book(@Body() dto: BookPackageDto, @Request() req: any) { + return this.service.book(dto, req.user?.passengerId); + } +} diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts new file mode 100644 index 000000000..ec378f325 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -0,0 +1,101 @@ +import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreatePriceTierDto { + @ApiProperty({ example: 'HSC' }) + @IsString() seatType: string; + + @ApiProperty({ example: 'Regular Seat (HSC)' }) + @IsString() label: string; + + @ApiProperty({ example: 1023200 }) + @IsInt() @Min(0) priceMinor: number; + + @ApiProperty({ example: 100 }) + @IsInt() @Min(0) availableSeats: number; +} + +export class UpdatePriceTierDto { + @ApiPropertyOptional() @IsOptional() @IsString() seatType?: string; + @ApiPropertyOptional() @IsOptional() @IsString() label?: string; + @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number; + @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) availableSeats?: number; +} + +export class CreatePackageDto { + @ApiProperty({ example: 'KULUBBI-2025' }) + @IsString() code: string; + + @ApiProperty({ example: 'Kulubbi Gabriel Pilgrimage Package' }) + @IsString() name: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() description?: string; + + @ApiProperty() @IsUUID() outboundScheduleId: string; + @ApiProperty() @IsUUID() returnScheduleId: string; + @ApiProperty() @IsUUID() originStationId: string; + @ApiProperty() @IsUUID() destinationStationId: string; + + @ApiProperty({ example: '2025-07-24T07:00:00Z' }) + @IsDateString() boardingTime: string; + + @ApiProperty({ example: '2025-07-24T09:00:00Z' }) + @IsDateString() departureTime: string; + + @ApiProperty({ example: '2025-07-25T06:00:00Z' }) + @IsDateString() arrivalTime: string; + + @ApiProperty({ example: 912 }) + @IsInt() @Min(1) totalCapacity: number; + + @ApiPropertyOptional({ example: '1 Locomotive + 2SBC + 2HBC + 6HSC' }) + @IsOptional() @IsString() coachConfiguration?: string; + + @ApiProperty({ type: [String] }) + @IsArray() @IsString({ each: true }) includedServices: string[]; + + @ApiPropertyOptional() @IsOptional() @IsBoolean() busTransferIncluded?: boolean; + @ApiPropertyOptional() @IsOptional() @IsString() busTransferRoute?: string; + + @ApiProperty({ example: '2025-07-01T00:00:00Z' }) + @IsDateString() validFrom: string; + + @ApiProperty({ example: '2025-07-24T09:00:00Z' }) + @IsDateString() validUntil: string; + + @ApiProperty({ type: [CreatePriceTierDto] }) + @IsArray() @ValidateNested({ each: true }) @Type(() => CreatePriceTierDto) + priceTiers: CreatePriceTierDto[]; +} + +export class BookPackagePassengerDto { + @ApiProperty() @IsString() passengerName: string; + @ApiPropertyOptional() @IsOptional() @IsDateString() dateOfBirth?: string; + @ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string; + @ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string; + @ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string; + @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; +} + +export class BookPackageDto { + @ApiProperty() @IsUUID() packageId: string; + @ApiProperty() @IsUUID() priceTierId: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() displayCurrency?: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() contactEmail?: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() contactPhone?: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() promoCode?: string; + + @ApiProperty({ type: [BookPackagePassengerDto] }) + @IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto) + passengers: BookPackagePassengerDto[]; +} diff --git a/apps/edr-passenger-api/src/modules/packages/packages.module.ts b/apps/edr-passenger-api/src/modules/packages/packages.module.ts new file mode 100644 index 000000000..f84a23781 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../common/prisma.module'; +import { PackagesController } from './packages.controller'; +import { PackagesService } from './packages.service'; +import { CurrencyModule } from '../currency/currency.module'; + +@Module({ + imports: [PrismaModule, CurrencyModule], + controllers: [PackagesController], + providers: [PackagesService], + exports: [PackagesService], +}) +export class PackagesModule {} diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts new file mode 100644 index 000000000..671e8b1c5 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -0,0 +1,238 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CurrencyService } from '../currency/currency.service'; +import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto'; +import { Currency } from '@prisma/client'; + +function generateRef(): string { + return 'PKG-' + Array.from({ length: 6 }, () => + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)], + ).join(''); +} + +@Injectable() +export class PackagesService { + constructor( + private readonly prisma: PrismaService, + private readonly currencyService: CurrencyService, + ) {} + + listActive() { + const now = new Date(); + return this.prisma.travelPackage.findMany({ + where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } }, + include: { + priceTiers: true, + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + orderBy: { validFrom: 'asc' }, + }); + } + + async getById(id: string) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id }, + include: { + priceTiers: true, + outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + if (!pkg) throw new NotFoundException('Package not found'); + return pkg; + } + + create(dto: CreatePackageDto) { + return this.prisma.travelPackage.create({ + data: { + code: dto.code, + name: dto.name, + description: dto.description, + outboundScheduleId: dto.outboundScheduleId, + returnScheduleId: dto.returnScheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + boardingTime: new Date(dto.boardingTime), + departureTime: new Date(dto.departureTime), + arrivalTime: new Date(dto.arrivalTime), + totalCapacity: dto.totalCapacity, + coachConfiguration: dto.coachConfiguration, + includedServices: dto.includedServices, + busTransferIncluded: dto.busTransferIncluded ?? false, + busTransferRoute: dto.busTransferRoute, + validFrom: new Date(dto.validFrom), + validUntil: new Date(dto.validUntil), + status: 'DRAFT', + priceTiers: { create: dto.priceTiers }, + }, + include: { priceTiers: true }, + }); + } + + async update(id: string, dto: Partial) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); + if (!pkg) throw new NotFoundException('Package not found'); + return this.prisma.travelPackage.update({ + where: { id }, + data: { + ...(dto.code && { code: dto.code }), + ...(dto.name && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.outboundScheduleId && { outboundScheduleId: dto.outboundScheduleId }), + ...(dto.returnScheduleId && { returnScheduleId: dto.returnScheduleId }), + ...(dto.originStationId && { originStationId: dto.originStationId }), + ...(dto.destinationStationId && { destinationStationId: dto.destinationStationId }), + ...(dto.boardingTime && { boardingTime: new Date(dto.boardingTime) }), + ...(dto.departureTime && { departureTime: new Date(dto.departureTime) }), + ...(dto.arrivalTime && { arrivalTime: new Date(dto.arrivalTime) }), + ...(dto.totalCapacity && { totalCapacity: dto.totalCapacity }), + ...(dto.coachConfiguration !== undefined && { coachConfiguration: dto.coachConfiguration }), + ...(dto.includedServices && { includedServices: dto.includedServices }), + ...(dto.busTransferIncluded !== undefined && { busTransferIncluded: dto.busTransferIncluded }), + ...(dto.busTransferRoute !== undefined && { busTransferRoute: dto.busTransferRoute }), + ...(dto.validFrom && { validFrom: new Date(dto.validFrom) }), + ...(dto.validUntil && { validUntil: new Date(dto.validUntil) }), + }, + include: { priceTiers: true }, + }); + } + + async addTier(packageId: string, dto: CreatePriceTierDto) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } }); + if (!pkg) throw new NotFoundException('Package not found'); + return this.prisma.packagePriceTier.create({ data: { ...dto, packageId } }); + } + + async updateTier(tierId: string, dto: UpdatePriceTierDto) { + const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); + if (!tier) throw new NotFoundException('Price tier not found'); + return this.prisma.packagePriceTier.update({ where: { id: tierId }, data: dto }); + } + + async deleteTier(tierId: string) { + const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); + if (!tier) throw new NotFoundException('Price tier not found'); + if (tier.bookedSeats > 0) throw new BadRequestException('Cannot delete a tier that has bookings'); + return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); + } + + async activate(id: string) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); + if (!pkg) throw new NotFoundException('Package not found'); + return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } }); + } + + async book(dto: BookPackageDto, passengerId?: string) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id: dto.packageId }, + include: { priceTiers: true }, + }); + if (!pkg) throw new NotFoundException('Package not found'); + if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); + if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired'); + + const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); + if (!tier) throw new NotFoundException('Price tier not found'); + + const passengerCount = dto.passengers.length; + const remaining = tier.availableSeats - tier.bookedSeats; + if (passengerCount > remaining) { + throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); + } + + const totalMinor = tier.priceMinor * passengerCount; + const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; + const displayTotalMinor = + displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const [booking] = await this.prisma.$transaction([ + this.prisma.packageBooking.create({ + data: { + bookingRef: generateRef(), + packageId: dto.packageId, + priceTierId: dto.priceTierId, + passengerId: passengerId ?? null, + contactEmail: dto.contactEmail, + contactPhone: dto.contactPhone, + promoCode: dto.promoCode, + passengerCount, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + status: 'PENDING_PAYMENT', + passengers: { + create: dto.passengers.map((p) => ({ + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth) : undefined, + idDocumentType: p.idDocumentType as any, + idDocumentNumber: p.idDocumentNumber, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + })), + }, + }, + include: { + passengers: true, + priceTier: true, + package: { + include: { + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }, + }, + }), + this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengerCount } }, + }), + ]); + + return booking; + } + + getMyBookings(passengerId: string) { + return this.prisma.packageBooking.findMany({ + where: { passengerId }, + include: { package: true, priceTier: true, passengers: true, paymentIntent: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getBookingByRef(bookingRef: string) { + const booking = await this.prisma.packageBooking.findUnique({ + where: { bookingRef }, + include: { + package: { + include: { + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }, + priceTier: true, + passengers: true, + paymentIntent: true, + }, + }); + if (!booking) throw new NotFoundException('Package booking not found'); + return booking; + } + + async listAll(page = 1, pageSize = 20) { + const skip = (page - 1) * pageSize; + const [items, total] = await Promise.all([ + this.prisma.travelPackage.findMany({ + skip, + take: pageSize, + include: { priceTiers: true }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.travelPackage.count(), + ]); + return { items, total, page, pageSize }; + } +} 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 24aadbd4c..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,15 +1,16 @@ -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 { 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'; -import { IamGuard } from '../../common/iam-adapter'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { PrismaService } from '../../common/prisma.service'; @ApiTags('Passengers') @Controller('passengers') +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PassengersController { constructor( private service: PassengersService, @@ -18,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' @@ -53,25 +55,17 @@ export class PassengersController { }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' }) async getMe(@Request() req: any) { - if (!req.user || !req.user.userId) { + if (!req.user || !req.user.id) { throw new UnauthorizedException('User not authenticated'); } try { - const user = await this.prisma.user.findUnique({ - where: { id: req.user.userId }, - include: { - passenger: true, - }, + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: req.user.id }, }); - - if (!user || !user.passenger) { - return null; - } - - return this.service.getProfile(user.passenger.id); + if (!passenger) return null; + return this.service.getProfile(passenger.id); } catch (error) { - // If profile lookup fails for any reason, return null to allow app to continue return null; } } @@ -93,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** @@ -162,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({ @@ -251,11 +247,12 @@ The API automatically detects: description: 'Invalid JWT token (only if token provided but invalid)' }) registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) { - const userId = req.user?.userId; + const userId = req.user?.id; return this.service.registerPassenger({ ...dto, userId }); } @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** @@ -354,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' @@ -365,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' @@ -376,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 7171b56ad..893bdb6ae 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto'; import { VerifaydaService } from '../verifayda/verifayda.service'; @@ -10,37 +12,42 @@ interface PassengerFilters { pageSize?: number; } +type IamUserRow = { + id: string; + email: string; + name: { en: string; am: string } | null; + phone_number: string | null; + metadata: Record | null; +}; + @Injectable() export class PassengersService { constructor( - private prisma: PrismaService, - private verifaydaService: VerifaydaService, + private readonly prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) {} async findAll(filters: PassengerFilters = {}) { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - - const where: any = { user: { role: 'PASSENGER' } }; - + + const where: any = {}; + if (search) { where.user = { - ...where.user, OR: [ - { fullName: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, { phone: { contains: search, mode: 'insensitive' } }, + { fullName: { contains: search, mode: 'insensitive' } }, ], }; } - + if (verified !== undefined) { - where.user = { - ...where.user, - nationalId: verified ? { not: null } : null, - }; + where.user = { ...(where.user ?? {}), faydaVerified: verified }; } - + const [items, total] = await Promise.all([ this.prisma.passenger.findMany({ where, @@ -51,39 +58,99 @@ export class PassengersService { user: true, loyalty: true, wallet: true, - _count: { + _count: { select: { bookings: true } }, + bookings: { + orderBy: { createdAt: 'desc' }, + take: 1, select: { - bookings: true, + 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 }), ]); - + + const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + 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 user = passenger.user as any; + const localUser = (passenger as any).user ?? null; + const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; + 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, - userId: passenger.userId, - fullName: user.fullName, - email: user.email, - phone: user.phone?.startsWith('+guest-') ? null : user.phone, - nationalId: user.nationalId, - nationality: user.nationality, - dateOfBirth: user.dateOfBirth ?? null, - gender: user.gender ?? null, - passportNumber: user.passportNumber, - passportCountry: user.passportCountry ?? null, - verified: !!user.nationalId, + 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, createdAt: passenger.createdAt, - updatedAt: user.updatedAt, - loyalty: passenger.loyalty, - wallet: passenger.wallet, }; }), meta: { @@ -99,33 +166,42 @@ export class PassengersService { const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { - user: true, - bookings: { - orderBy: { createdAt: 'desc' }, - take: 10, - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } } - } + bookings: { + orderBy: { createdAt: 'desc' }, + take: 10, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } }, + }, }, - loyalty: true, - wallet: true, - travelerProfiles: true, + loyalty: true, + wallet: true, + travelerProfiles: true, savedRoutes: true, }, }); if (!passenger) throw new NotFoundException('Passenger not found'); + + let iamUser: IamUserRow | null = null; + if (passenger.iamUserId) { + const rows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [passenger.iamUserId], + ); + iamUser = rows[0] ?? null; + } + return { id: passenger.id, - fullName: passenger.user.fullName, - email: passenger.user.email, - phone: passenger.user.phone, + fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, + email: iamUser?.email ?? null, + phone: iamUser?.phone_number ?? null, createdAt: passenger.createdAt, bookings: passenger.bookings.map((b) => ({ - id: b.id, - bookingRef: b.bookingRef, - status: b.status, - totalFare: b.totalMinor / 100, + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalFare: b.totalMinor / 100, createdAt: b.createdAt, trip: { number: b.schedule.train.number, @@ -143,13 +219,9 @@ export class PassengersService { }, departureAt: b.schedule.departureAt, }, - passengers: b.seats.map((bs) => ({ - fullName: bs.passengerName, - seat: { - number: bs.seat.seatNumber, - coach: bs.seat.coach.number, - class: 'N/A' - } + passengers: b.seats.map((bs) => ({ + fullName: bs.passengerName, + seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' }, })), })), }; @@ -157,11 +229,11 @@ export class PassengersService { async getStats(passengerId: string) { const [totalTrips, totalSpendResult, loyalty] = await Promise.all([ - this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }), - this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }), + this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }), + this.prisma.booking.aggregate({ where: { passengerId, status: 'BOARDED' as any }, _sum: { totalMinor: true } }), this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }), ]); - const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100; + const totalSpend = ((totalSpendResult._sum?.totalMinor ?? 0) as number) / 100; return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 }; } @@ -229,23 +301,53 @@ export class PassengersService { async updatePassenger(id: string, dto: any) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - return this.prisma.passenger.update({ - where: { id }, - data: { - user: { - update: { - fullName: dto.fullName || undefined, - email: dto.email || undefined, - phone: dto.phone || undefined, - nationality: dto.nationality || undefined, - }, - }, - }, - include: { - user: true, - loyalty: true, - }, - }); + + if (passenger.iamUserId && (dto.fullName || dto.email || dto.phone)) { + const updates: string[] = []; + const params: any[] = []; + let idx = 1; + + if (dto.fullName) { + updates.push(`name = COALESCE(name, '{}') || jsonb_build_object('en', $${idx}::text, 'am', $${idx}::text)`); + params.push(dto.fullName); + idx++; + } + if (dto.email) { + updates.push(`email = $${idx}`); + params.push(dto.email); + idx++; + } + if (dto.phone) { + updates.push(`phone_number = $${idx}`); + params.push(dto.phone); + idx++; + } + + params.push(passenger.iamUserId); + await this.dataSource.query( + `UPDATE iam.users SET ${updates.join(', ')} WHERE id = $${idx}`, + params, + ); + } + + const [updated, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ where: { id }, include: { loyalty: true } }), + passenger.iamUserId + ? this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + [passenger.iamUserId], + ) + : Promise.resolve([] as IamUserRow[]), + ]); + + const iamUser = iamRows[0] ?? null; + return { + id: updated!.id, + fullName: iamUser?.name?.en ?? iamUser?.name?.am ?? null, + email: iamUser?.email ?? null, + phone: iamUser?.phone_number ?? null, + loyalty: updated!.loyalty, + }; } async registerPassenger(dto: RegisterPassengerDto) { @@ -274,31 +376,16 @@ export class PassengersService { }; if (isLoggedIn) { - const user = await this.prisma.user.findUnique({ - where: { id: dto.userId }, - include: { passenger: true }, + const linkedPassenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: dto.userId }, }); - if (!user) { - throw new BadRequestException('User not found'); - } - - if (!user.faydaVerified && verifiedData) { - await this.prisma.user.update({ - where: { id: dto.userId }, - data: { - fullName: finalData.passengerName, - nationality: finalData.nationality, - nationalId: dto.nationalId, - passportNumber: dto.passportNumber, - faydaVerified: !!verifiedData, - faydaVerifiedAt: verifiedData ? new Date() : null, - }, - }); + if (!linkedPassenger) { + throw new BadRequestException('Passenger not found'); } return { - id: user.passenger?.id || user.id, + id: linkedPassenger.id, passengerName: finalData.passengerName, dateOfBirth: finalData.dateOfBirth, nationality: finalData.nationality, @@ -336,7 +423,23 @@ export class PassengersService { async deletePassenger(id: string) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - return 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 }; } async checkPassengerUsage(id: string) { diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index 98262c4c3..04b721f0c 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -7,6 +7,7 @@ import { UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { SkipThrottle } from "@nestjs/throttler"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; import { PaymentsService } from "./payments.service"; @@ -20,6 +21,7 @@ import { PaymentsService } from "./payments.service"; @ApiTags("Internal Payments") @UseGuards(ServiceAuthGuard) @Controller("internal/payments") +@SkipThrottle() export class InternalPaymentsController { constructor(private readonly paymentsService: PaymentsService) {} 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 a64c4ae9c..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,6 +18,8 @@ import { ApiOkResponse, ApiProduces, } from "@nestjs/swagger"; + +import { SkipThrottle, Throttle } from "@nestjs/throttler"; import { Response } from "express"; import { PaymentsService } from "./payments.service"; import { @@ -28,20 +31,18 @@ import { PaymentMethodTypeEnum, PaymentPlatformDto, } from "./payments.dto"; -import { JwtGuard } from "../../common/jwt.guard"; -import { RolesGuard } from "../../common/roles.guard"; -import { Roles } from "../../common/roles.decorator"; -import { UserRole } from "@prisma/client"; +import { PassengerStaff } from "../../common/passenger-guards"; +import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Payment") @Controller("payments") +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PaymentsController { constructor(private service: PaymentsService) {} @Get("all") - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF) - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) @ApiQuery({ name: "search", required: false }) @ApiQuery({ name: "status", required: false }) @@ -65,6 +66,7 @@ export class PaymentsController { } @Post("initiate") + @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`, @@ -74,12 +76,14 @@ export class PaymentsController { } @Get("intents/:bookingId") + @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") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + @@ -102,18 +106,16 @@ export class PaymentsController { } @Post("refund") - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); } @Post("methods") - @UseGuards(JwtGuard, RolesGuard) - @Roles(UserRole.ADMIN, UserRole.STAFF) - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Add a payment system to the platform catalog (admin only)", }) @@ -122,6 +124,7 @@ export class PaymentsController { } @Get("methods") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "List payment systems supported by the platform", description: @@ -134,6 +137,7 @@ export class PaymentsController { } @Get("checkout") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Browser checkout redirect", description: diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 0fd594b39..5393696bf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -23,19 +23,7 @@ describe("Payments E2E", () => { prisma = app.get(PrismaService); - const testUser = await prisma.user.create({ - data: { - email: "payment-test@example.com", - phone: "+251911111112", - fullName: "Payment Test User", - passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz", - role: "PASSENGER", - }, - }); - - const passenger = await prisma.passenger.create({ - data: { userId: testUser.id }, - }); + const passenger = await prisma.passenger.create({ data: { iamUserId: 'test-iam-payments-user' } }); await prisma.walletAccount.create({ data: { @@ -151,7 +139,6 @@ describe("Payments E2E", () => { prisma.walletLedgerEntry.deleteMany(), prisma.walletAccount.deleteMany(), prisma.passenger.deleteMany(), - prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }), ]); await app.close(); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 8b68b637d..7e387bb31 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -61,5 +61,6 @@ function rabbitMQImport(): DynamicModule[] { PaymentEventsConsumer, ServiceAuthGuard, ], + exports: [PaymentClientService], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 13569ffc4..7453e1c06 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -447,7 +447,7 @@ export class PaymentsService { include: { seats: true }, }); if (booking) { - await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + await this.seatsService.releaseSeats(booking.id); await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: "CANCELLED" }, @@ -746,51 +746,125 @@ export class PaymentsService { private async createJourneySegments( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ) { - const schedule = await this.prisma.trainSchedule.findUnique({ - where: { id: booking.scheduleId }, - include: { - stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, - }, - }); - if (!schedule) return; + const b = booking as any; - const stopTimes = schedule.stopTimes; - if (stopTimes.length < 2) return; + // Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] } + // BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2 + type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] }; + const legDefs: LegDef[] = []; - const originSequence = stopTimes.findIndex( - (st) => st.stationId === schedule.originStationId, - ); - const destSequence = stopTimes.findIndex( - (st) => st.stationId === schedule.destinationStationId, - ); + const seatsForLeg = (legNum: number) => + booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId); - if ( - originSequence < 0 || - destSequence < 0 || - originSequence >= destSequence - ) - return; + if (booking.bookingType === 'ONE_WAY') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.destinationStationId, + seatIds: booking.seats.map((s: any) => s.seatId), + }); + } else if (booking.bookingType === 'ROUND_TRIP') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.destinationStationId, + seatIds: seatsForLeg(1), + }); + if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { + legDefs.push({ + scheduleId: b.returnScheduleId, + originStationId: b.returnOriginStationId, + destinationStationId: b.returnDestinationStationId, + seatIds: seatsForLeg(2), + }); + } + } else if (booking.bookingType === 'TRANSIT') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.leg2OriginStationId, // transit station + seatIds: seatsForLeg(1), + }); + if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { + legDefs.push({ + scheduleId: b.leg2ScheduleId, + originStationId: b.leg2OriginStationId, + destinationStationId: b.leg2DestinationStationId, + seatIds: seatsForLeg(2), + }); + } + } else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') { + legDefs.push({ + scheduleId: booking.scheduleId, + originStationId: b.originStationId, + destinationStationId: b.leg2OriginStationId, + seatIds: seatsForLeg(1), + }); + if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { + legDefs.push({ + scheduleId: b.leg2ScheduleId, + originStationId: b.leg2OriginStationId, + destinationStationId: b.leg2DestinationStationId, + seatIds: seatsForLeg(2), + }); + } + if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { + legDefs.push({ + scheduleId: b.returnScheduleId, + originStationId: b.returnOriginStationId, + destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId, + seatIds: seatsForLeg(3), + }); + } + if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) { + legDefs.push({ + scheduleId: b.returnLeg2ScheduleId, + originStationId: b.returnLeg2OriginStationId, + destinationStationId: b.returnLeg2DestStationId, + seatIds: seatsForLeg(4), + }); + } + } + + if (legDefs.length === 0) return; const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, - status: "CONFIRMED", - totalMinor: booking.totalMinor, - currency: booking.currency, - }, + bookingId: booking.id, + status: 'CONFIRMED', + totalMinor: booking.totalMinor, + currency: booking.currency, + } as any, }); - const journeySegments = []; - for (const bookingSeat of booking.seats) { - for (let i = originSequence; i < destSequence; i++) { - journeySegments.push({ - journeyId: journey.id, - scheduleId: booking.scheduleId, - segmentOrder: i, - seatId: bookingSeat.seatId, - departureStationId: stopTimes[i].stationId, - arrivalStationId: stopTimes[i + 1].stationId, - }); + const journeySegments: any[] = []; + let segmentOrder = 0; + + for (const leg of legDefs) { + if (leg.seatIds.length === 0) continue; + + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: leg.scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true, sequence: true }, + }); + + const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId); + const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId); + if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue; + + for (const seatId of leg.seatIds) { + for (let i = originIdx; i < destIdx; i++) { + journeySegments.push({ + journeyId: journey.id, + scheduleId: leg.scheduleId, + segmentOrder: segmentOrder++, + seatId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } } } diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 772a1428c..0c0d02ea4 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -1,33 +1,30 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ReportsService } from './reports.service'; import { GenerateReportDto } from './reports.dto'; -import { IamGuard, IamRoles } from '../../common/iam-adapter'; -import { UserRole } from '@prisma/client'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Reports') @Controller('reports') -@UseGuards(IamGuard) +@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class ReportsController { constructor(private service: ReportsService) {} @Post('generate') - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'Generate operational report' }) generateReport(@Body() dto: GenerateReportDto) { return this.service.generateReport(dto); } @Get(':reportId') - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'Get report by ID' }) getReport(@Param('reportId') reportId: string) { return this.service.getReport(reportId); } @Get() - @IamRoles('ADMIN', 'SUPERVISOR') @ApiOperation({ summary: 'List reports' }) listReports(@Query('type') type?: string) { return this.service.listReports(type); diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index d1f25dde7..5c1b5e582 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1,10 +1,15 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { GenerateReportDto, ReportType } from './reports.dto'; @Injectable() export class ReportsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @InjectDataSource() private dataSource: DataSource, + ) {} async generateReport(dto: GenerateReportDto) { const dateFrom = new Date(dto.dateFrom); @@ -113,13 +118,25 @@ export class ReportsService { ...(agentId ? { agentId } : {}) }, include: { - agent: { include: { user: true } }, + agent: { select: { id: true, iamUserId: true, agentCode: true } }, booking: true } }); + const iamUserIds = [...new Set( + agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[] + )]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>( + `SELECT id, name FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + const byAgent = agentBookings.reduce((acc, ab) => { - const agentName = ab.agent.user.fullName; + const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined; + const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode; if (!acc[agentName]) { acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 378bc8fa7..ac55bc14b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -23,6 +24,7 @@ export class SchedulesController { createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } @Get() + @IsPublic() @ApiOperation({ summary: 'List schedules with optional filters' }) @ApiQuery({ name: 'date', required: false }) @ApiQuery({ name: 'routeId', required: false }) @@ -67,6 +69,7 @@ export class SchedulesController { createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } @Get('routes/:routeId/segment-fares') + @IsPublic() @ApiOperation({ summary: 'List all segment fare rules for a route' }) @ApiParam({ name: 'routeId', description: 'Route UUID' }) getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } @@ -86,6 +89,7 @@ export class SchedulesController { // ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) ===== @Get(':id') + @IsPublic() @ApiOperation({ summary: 'Get schedule detail' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } @@ -113,6 +117,7 @@ export class SchedulesController { deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); } @Get(':id/stops') + @IsPublic() @ApiOperation({ summary: 'List all stops for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) getStops(@Param('id') id: string) { return this.service.getStops(id); } diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index b32f2f291..6bb9d1960 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -1,10 +1,12 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; import { SearchTripsDto, FareQuoteDto } from './search.dto'; @ApiTags('Search') @Controller('search') +@IsPublic() export class SearchController { constructor(private service: SearchService) {} diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 9087ee7ac..4797c6c19 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -39,6 +39,23 @@ export class SearchService { const outbound = [...direct, ...transit]; + if (outbound.length === 0) { + const alternativesOutbound = await this.searchAlternatives( + dto.originStationId, + dto.destinationStationId, + dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ); + return { + journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', + outbound: [], + alternativeOutbound: alternativesOutbound, + requestedDate: dto.date, + }; + } + if (dto.journeyType === 'ROUND_TRIP') { const [returnDirect, returnTransit] = await Promise.all([ this.searchSchedules( @@ -68,12 +85,85 @@ export class SearchService { new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); + if (inbound.length === 0) { + const alternativeInbound = await this.searchAlternatives( + dto.destinationStationId, + dto.originStationId, + dto.returnDate ?? dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ); + return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound }; + } + return { journeyType: 'ROUND_TRIP', outbound, inbound }; } return { journeyType: 'ONE_WAY', outbound }; } + private async searchAlternatives( + originStationId: string, + destinationStationId: string, + dateStr: string, + adultCount: number, + childCount?: number, + nationality?: string, + ) { + const [y, m, d] = dateStr.split('-').map(Number); + const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0); + + const now = new Date(); + const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000)); + const daysAfter = 14 - daysBefore; + + const windowStart = new Date(requestedDate); + windowStart.setDate(windowStart.getDate() - daysBefore); + if (windowStart < now) windowStart.setTime(now.getTime()); + + const windowEnd = new Date(requestedDate); + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + + const totalPassengers = adultCount + (childCount ?? 0); + + const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + OR: [ + { departureAt: { gte: windowStart, lt: requestedDate } }, + { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, + ], + stopTimes: { some: { stationId: originStationId } }, + }, + include: { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, + }, + orderBy: { departureAt: 'asc' }, + }); + + const results: any[] = []; + for (const schedule of schedules) { + const result = await this.buildScheduleResult( + schedule, + originStationId, + destinationStationId, + totalPassengers, + nationality, + ); + if (result) results.push(result); + } + return results; + } + private async searchSchedules( originStationId: string, destinationStationId: string, @@ -85,12 +175,13 @@ export class SearchService { const [y, m, d] = dateStr.split('-').map(Number); const date = new Date(y, m - 1, d, 0, 0, 0, 0); const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: date, lt: nextDay }, + departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, include: { diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 0ac25272d..86eb287a4 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SeatClassesService } from './seat-classes.service'; import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -10,11 +11,13 @@ export class SeatClassesController { constructor(private service: SeatClassesService) {} @Get() + @IsPublic() @ApiOperation({ summary: 'List all seat classes' }) @ApiResponse({ status: 200, description: 'Returns all seat classes with their coaches' }) listSeatClasses() { return this.service.listSeatClasses(); } @Get(':id') + @IsPublic() @ApiOperation({ summary: 'Get a seat class by ID' }) @ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiResponse({ status: 200, description: 'Returns seat class with its coaches' }) 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 8914ca465..b946e7537 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -1,36 +1,65 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { SeatsService } from './seats.service'; -import { HoldSeatsDto } from './seats.dto'; -import { JwtGuard } from '../../common/jwt.guard'; -import { IamGuard } from '../../common/iam-adapter'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Patch, + Query, + SetMetadata, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, + ApiQuery, + ApiResponse, +} from "@nestjs/swagger"; +import { SeatsService } from "./seats.service"; +import { HoldSeatsDto } from "./seats.dto"; +import { JwtGuard } from "../../common/jwt.guard"; +import { IamGuard } from "../../common/iam-adapter"; -@ApiTags('Seats') -@Controller('seats') +@ApiTags("Seats") +@Controller("seats") export class SeatsController { constructor(private service: SeatsService) {} // ── Seat Map ────────────────────────────────────────────────────────────── - @Get('seatmap/:scheduleId') - @ApiOperation({ - summary: 'Get seat map with real-time availability by class', - description: `Returns seat map for a schedule with availability by seat class: -- Economy Regular -- Economy Bed -- VIP Bed - -Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED` + @Get("seatmap/:scheduleId") + @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.`, }) - @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' }) - @ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' }) - getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); } + @ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" }) + @ApiQuery({ + name: "coachTypeId", + required: false, + description: + "Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)", + }) + @ApiResponse({ + status: 200, + description: + "List of coaches of the given type with their seats and availability", + }) + getSeatMap( + @Param("scheduleId") scheduleId: string, + @Query("coachTypeId") coachTypeId?: string, + ) { + return this.service.getSeatMap(scheduleId, coachTypeId); + } // ── Hold / Release ──────────────────────────────────────────────────────── - @Get('holds') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @Get("holds") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") @ApiOperation({ - summary: 'List active seat holds with full leg context', + summary: "List active seat holds with full leg context", description: `Returns all non-expired holds enriched with: - **schedule**: train number, departure/arrival, full route origin→destination - **leg**: the specific origin→destination this hold covers (station name, code, stop sequence) @@ -39,25 +68,46 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED` This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`, }) - @ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' }) - @ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' }) - @ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' }) + @ApiQuery({ + name: "scheduleId", + required: false, + description: "Filter by TrainSchedule UUID", + }) + @ApiQuery({ + name: "passengerId", + required: false, + description: "Filter by Passenger UUID", + }) + @ApiResponse({ + status: 200, + description: "Active holds with schedule, leg, and seat details", + }) getHolds( - @Query('scheduleId') scheduleId?: string, - @Query('passengerId') passengerId?: string, - ) { return this.service.getHolds(scheduleId, passengerId); } + @Query("scheduleId") scheduleId?: string, + @Query("passengerId") passengerId?: string, + ) { + return this.service.getHolds(scheduleId, passengerId); + } - @Get('holds/:holdId') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get a single hold with full leg context' }) - @ApiParam({ name: 'holdId', description: 'SeatHold UUID' }) - @ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' }) - @ApiResponse({ status: 404, description: 'Hold not found' }) - getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); } + @Get("holds/:holdId") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get a single hold with full leg context" }) + @ApiParam({ name: "holdId", description: "SeatHold UUID" }) + @ApiResponse({ + status: 200, + description: "Hold with schedule, leg, and seat details", + }) + @ApiResponse({ status: 404, description: "Hold not found" }) + getHold(@Param("holdId") holdId: string) { + return this.service.getHold(holdId); + } - @Post('hold') - @ApiOperation({ - summary: 'Hold seats for 15 minutes before booking (Public - Guest booking supported)', + @Post("hold") + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: + "Hold seats for 15 minutes before booking (Public - Guest booking supported)", description: `Temporarily reserves seats for a passenger to complete booking. **Features:** @@ -65,74 +115,107 @@ This makes it clear which segment of the route each seat is held for, enabling s - Auto-release after expiry - Prevents double booking - Required before creating booking -- **Public endpoint** - No authentication required (supports guest booking)` +- **Public endpoint** - No authentication required (supports guest booking)`, }) - @ApiResponse({ status: 201, description: 'Seats held successfully with holdId' }) - @ApiResponse({ status: 409, description: 'One or more seats unavailable' }) - holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); } + @ApiResponse({ + status: 201, + description: "Seats held successfully with holdId", + }) + @ApiResponse({ status: 409, description: "One or more seats unavailable" }) + holdSeats(@Body() dto: HoldSeatsDto) { + return this.service.holdSeats(dto); + } - @Delete('hold/:holdId') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Release a seat hold' }) - @ApiParam({ name: 'holdId', description: 'Hold UUID' }) - @ApiResponse({ status: 200, description: 'Hold released' }) - @ApiResponse({ status: 404, description: 'Hold not found' }) - releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); } + @Delete("hold/:holdId") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Release a seat hold" }) + @ApiParam({ name: "holdId", description: "Hold UUID" }) + @ApiResponse({ status: 200, description: "Hold released" }) + @ApiResponse({ status: 404, description: "Hold not found" }) + releaseHold(@Param("holdId") holdId: string) { + return this.service.releaseHold(holdId); + } // ── Seat Block / Unblock ─────────────────────────────────────────────────── - @Post(':seatId/block') - @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' }) - @ApiParam({ name: 'seatId', description: 'Seat UUID' }) - @ApiResponse({ status: 200, description: 'Seat blocked' }) - blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) { + @Post(":seatId/block") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat blocked" }) + blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) { return this.service.blockSeat(seatId, body.reason); } - @Delete(':seatId/block') - @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Unblock a seat' }) - @ApiParam({ name: 'seatId', description: 'Seat UUID' }) - @ApiResponse({ status: 200, description: 'Seat unblocked' }) - unblockSeat(@Param('seatId') seatId: string) { + @Delete(":seatId/block") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Unblock a seat" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat unblocked" }) + unblockSeat(@Param("seatId") seatId: string) { return this.service.unblockSeat(seatId); } // ── Remove Seat ──────────────────────────────────────────────────────────── - @Patch(':seatId/remove') - @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' }) - @ApiParam({ name: 'seatId', description: 'Seat UUID' }) - @ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' }) - @ApiResponse({ status: 404, description: 'Seat not found' }) - removeSeat(@Param('seatId') seatId: string) { + @Patch(":seatId/remove") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Remove a seat by marking with negative seatNumber", + }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ + status: 200, + description: "Seat removed (seatNumber negated), shows as empty space", + }) + @ApiResponse({ status: 404, description: "Seat not found" }) + removeSeat(@Param("seatId") seatId: string) { return this.service.removeSeat(seatId); } - @Patch(':seatId/undo-remove') - @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' }) - @ApiParam({ name: 'seatId', description: 'Seat UUID' }) - @ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' }) - @ApiResponse({ status: 404, description: 'Seat not found' }) - @ApiResponse({ status: 400, description: 'Seat is not removed' }) - undoRemoveSeat(@Param('seatId') seatId: string) { + @Patch(":seatId/undo-remove") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Undo seat removal by restoring original seatNumber", + }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ + status: 200, + description: "Seat restored (negative seatNumber removed)", + }) + @ApiResponse({ status: 404, description: "Seat not found" }) + @ApiResponse({ status: 400, description: "Seat is not removed" }) + undoRemoveSeat(@Param("seatId") seatId: string) { return this.service.undoRemoveSeat(seatId); } - @Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' }) - async exportCSV(@Param('scheduleId') scheduleId: string) { + @Get("export/csv/:scheduleId") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Export seats as CSV" }) + async exportCSV(@Param("scheduleId") scheduleId: string) { const csv = await this.service.exportSeatsCSV(scheduleId); return { csv, filename: `seats-${scheduleId}.csv` }; } - @Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' }) + @Post("import/preview") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Preview CSV import" }) previewCSV(@Body() body: { csv: string }) { return this.service.previewSeatsCSV(body.csv); } - @Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' }) - importCSV(@Body() body: { scheduleId: string; csv: string; commit: boolean }) { + @Post("import/commit") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Commit CSV import" }) + importCSV( + @Body() body: { scheduleId: string; csv: string; commit: boolean }, + ) { return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index a21797e0f..425f517c6 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -3,10 +3,10 @@ import { HttpModule } from '@nestjs/axios'; import { SeatsController } from './seats.controller'; import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; -import { IamModule } from '../../common/iam.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; @Module({ - imports: [SegmentsModule, HttpModule, IamModule], + imports: [SegmentsModule, HttpModule, SystemConfigModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 21060b595..8868bb91d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -3,17 +3,28 @@ import { PrismaService } from '../../common/prisma.service'; import { HoldSeatsDto } from './seats.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; +import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; @Injectable() export class SeatsService { constructor( private prisma: PrismaService, private segmentsService: SegmentsService, + private systemConfig: SystemConfigService, ) {} - async getSeatMap(scheduleId: string, coachId?: string) { + async getSeatMap(scheduleId: string, coachTypeId?: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + const assignments = await this.prisma.coachAssignment.findMany({ - where: { scheduleId, ...(coachId ? { coachId } : {}) }, + where: { + scheduleId, + ...(coachTypeId ? { coach: { coachTypeId } } : {}), + }, include: { coach: { include: { @@ -25,86 +36,198 @@ export class SeatsService { orderBy: { positionNumber: 'asc' }, }); - console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`); - const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds); + const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId); - const response = { + return { coaches: assignments.map((a) => { - const allSeats = a.coach.seats; + const allSeats = a.coach.seats; + const coachTypeName = a.coach.coachType?.name ?? ''; const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); - - return { - id: a.coach.id, - assignmentId: a.id, - coachNumber: a.coach.number, - label: a.coach.number, - mode: a.coach.status, - name: `Coach ${a.coach.number}`, - seatClasses: seatClassNames, - seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', + const isBedCoach = this.isBedCoach(coachTypeName); + // Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6) + const bedsPerRoom = isBedCoach + ? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length + : 0; + const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null; + + const mappedSeats = allSeats.map((s: any) => ({ + id: s.id, + seatNumber: s.seatNumber, + label: s.seatNumber, + status: effectiveStatuses.get(s.id) ?? s.status, + kind: s.kind, + row: s.row, + col: s.col, + isWindow: s.isWindow, + isAisle: s.isAisle, + // Bed-specific fields + ...(isBedCoach ? { + room_id: `${a.coach.id}-R${s.row}`, + category: bedCategory, + position: this.colToPosition(s.col), + bed_type: this.bedPositionToType(s.bedPosition), + bedPosition: s.bedPosition, + } : { + bedPosition: s.bedPosition, + }), + })); + + const base = { + id: a.coach.id, + assignmentId: a.id, + coachNumber: a.coach.number, + label: a.coach.number, + mode: a.coach.status, + name: `Coach ${a.coach.number}`, + coachTypeName, + isBedCoach, + bedCategory, + seatClasses: seatClassNames, + seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', positionNumber: a.positionNumber, seatArrangement: a.coach.arrangement, - totalSeats: a.coach.capacity, - seats: allSeats.map((s) => ({ - id: s.id, - seatNumber: s.seatNumber, - number: s.seatNumber, - label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, - kind: s.kind, - row: s.row, - col: s.col, - isWindow: s.isWindow, - isAisle: s.isAisle, - bedPosition: s.bedPosition, - coach: { - id: a.coach.id, - coachNumber: a.coach.number, - label: a.coach.number, - }, - })), + totalSeats: a.coach.capacity, }; + + if (isBedCoach) { + // Group seats into rooms; row = room number + const roomMap = new Map(); + for (const seat of mappedSeats) { + if (!roomMap.has(seat.row)) roomMap.set(seat.row, []); + roomMap.get(seat.row)!.push(seat); + } + const rooms = Array.from(roomMap.entries()) + .sort(([a], [b]) => a - b) + .map(([roomNumber, beds]) => ({ + room_id: `${a.coach.id}-R${roomNumber}`, + roomNumber, + category: bedCategory, + totalBeds: beds.length, + beds, + })); + return { ...base, rooms, seats: mappedSeats }; + } + + return { ...base, seats: mappedSeats }; }), }; + } - console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`); - return response; + private isBedCoach(coachTypeName: string): boolean { + const n = coachTypeName.toLowerCase(); + return n.includes('bed') || n.includes('sleeper') || n.includes('couchette'); + } + + private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' { + const n = coachTypeName.toLowerCase(); + // Explicit VIP name check first + if (n.includes('vip')) return 'VIP_BED'; + // Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy + if (bedsPerRoom === 4) return 'VIP_BED'; + return 'ECONOMY_BED'; + } + + // col format: L1, L2, L3, R1, R2, R3 + private colToPosition(col: string): 'LEFT' | 'RIGHT' { + return col?.startsWith('R') ? 'RIGHT' : 'LEFT'; + } + + private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null { + if (!bedPosition) return null; + const map: Record = { + lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER', + }; + return map[bedPosition.toLowerCase()] ?? null; } async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], + originStationId?: string, + destinationStationId?: string, ): Promise> { const statusMap = new Map(); - if (seatIds.length === 0) return statusMap; + // Resolve the requested leg's sequence range once + let reqFrom: number | undefined; + let reqTo: number | undefined; + let allStopTimes: { stationId: string; sequence: number }[] | null = null; + + const getStopTimes = async () => { + if (!allStopTimes) { + allStopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + } + return allStopTimes; + }; + + if (originStationId && destinationStationId) { + const stops = await getStopTimes(); + const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; + reqFrom = seqOf(originStationId); + reqTo = seqOf(destinationStationId); + } + + // ── Active holds ────────────────────────────────────────────────────────── const activeHolds = await this.prisma.seatHold.findMany({ - where: { - scheduleId, - expiresAt: { gt: new Date() }, - seatIds: { hasSome: seatIds }, - }, - select: { seatIds: true }, + where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } }, + select: { seatIds: true, createdBy: true }, }); + for (const hold of activeHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy?.trimStart().startsWith('{')) { + const meta = JSON.parse(hold.createdBy); + const stops = await getStopTimes(); + const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + for (const seatId of hold.seatIds) { - if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD'); + if (!seatIds.includes(seatId)) continue; + if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) { + if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD'); + } else { + statusMap.set(seatId, 'HELD'); + } } } + // ── Confirmed bookings via JourneySegment ───────────────────────────────── const bookedSegments = await this.prisma.journeySegment.findMany({ where: { scheduleId, seatId: { in: seatIds }, journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, }, - select: { seatId: true }, + select: { seatId: true, departureStationId: true, arrivalStationId: true }, }); - for (const seg of bookedSegments) { - if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); + + if (reqFrom !== undefined && reqTo !== undefined) { + const stops = await getStopTimes(); + const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence; + for (const seg of bookedSegments) { + if (!seg.seatId) continue; + const segFrom = seqOf(seg.departureStationId); + const segTo = seqOf(seg.arrivalStationId); + if (segFrom !== undefined && segTo !== undefined) { + if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED'); + } else { + statusMap.set(seg.seatId, 'BOOKED'); + } + } + } else { + for (const seg of bookedSegments) { + if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); + } } return statusMap; @@ -119,7 +242,25 @@ export class SeatsService { if (new Set(seatIds).size !== seatIds.length) throw new BadRequestException('Duplicate seatId in passengers list'); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + const [holdMinutes, cutoffHours] = await Promise.all([ + this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES), + this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE), + ]); + const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000); + + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { departureAt: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const msUntilDeparture = schedule.departureAt.getTime() - Date.now(); + const cutoffMs = cutoffHours * 60 * 60 * 1000; + if (msUntilDeparture <= cutoffMs) { + throw new BadRequestException( + `Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`, + ); + } const hold = await this.prisma.$transaction(async (tx) => { const seats = await tx.seat.findMany({ @@ -154,6 +295,7 @@ export class SeatsService { if (reqFrom >= reqTo) throw new BadRequestException('Origin must come before destination'); + // ── Check existing holds for overlap ──────────────────────────────────── const activeHolds = await tx.seatHold.findMany({ where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, select: { seatIds: true, createdBy: true }, @@ -197,6 +339,29 @@ export class SeatsService { } } + // ── Check confirmed JourneySegments for overlap ────────────────────────── + const bookedSegments = await tx.journeySegment.findMany({ + where: { + scheduleId: dto.scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, departureStationId: true, arrivalStationId: true }, + }); + + for (const seg of bookedSegments) { + if (!seg.seatId) continue; + const segFrom = seqOf(seg.departureStationId); + const segTo = seqOf(seg.arrivalStationId); + if (segFrom !== undefined && segTo !== undefined) { + if (segFrom < reqTo && reqFrom < segTo) { + throw new ConflictException( + `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, + ); + } + } + } + const holdMeta = { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, @@ -347,16 +512,12 @@ export class SeatsService { return { released: true, holdId }; } - async confirmSeats(seatIds: string[]) { - // No-op - } + // Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy. + async confirmSeats(_seatIds: string[]) {} - async releaseSeats(seatIds: string[]) { - if (seatIds.length > 0) { - await this.prisma.journeySegment.deleteMany({ - where: { seatId: { in: seatIds } }, - }); - } + // Delete the Journey (and its JourneySegments) scoped to this booking. + async releaseSeats(bookingId: string) { + await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { @@ -424,7 +585,7 @@ export class SeatsService { invalid++; continue; } - const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; + const [coachId, , row, col, seatNumber] = parts; if (!coachId || !row || !col || !seatNumber) { errors.push(`Line ${i + 2}: Missing required fields`); invalid++; @@ -448,7 +609,7 @@ export class SeatsService { for (let i = 0; i < lines.length; i++) { try { const parts = lines[i].split(','); - const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; + const [coachId, , row, col, seatNumber, kind, status, premiumFeeMinor] = parts; await this.prisma.seat.upsert({ where: { coachId_row_col: { coachId, row: parseInt(row), col } }, @@ -481,18 +642,8 @@ export class SeatsService { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ - where: { id: seatId }, - data: { status: 'BLOCKED' }, - }); - - await this.prisma.seatBlock.create({ - data: { - seatId, - reason, - blockedBy: 'system', - }, - }); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); + await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); return { blocked: true, seatId, reason }; } @@ -501,14 +652,8 @@ export class SeatsService { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ - where: { id: seatId }, - data: { status: 'AVAILABLE' }, - }); - - await this.prisma.seatBlock.deleteMany({ - where: { seatId }, - }); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId } }); return { unblocked: true, seatId }; } @@ -518,11 +663,9 @@ export class SeatsService { if (!seat) throw new NotFoundException('Seat not found'); if (!seat.seatNumber) throw new BadRequestException('Seat already removed'); - // Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space - const negatedNumber = `-${seat.seatNumber}`; await this.prisma.seat.update({ where: { id: seatId }, - data: { seatNumber: negatedNumber }, + data: { seatNumber: `-${seat.seatNumber}` }, }); return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; @@ -535,26 +678,15 @@ export class SeatsService { throw new BadRequestException('Seat is not removed'); } - // Restore original seatNumber by removing the negative sign const originalNumber = seat.seatNumber.slice(1); - await this.prisma.seat.update({ - where: { id: seatId }, - data: { seatNumber: originalNumber }, - }); + await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } }); return { restored: true, seatId, seatNumber: originalNumber }; } @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { - const now = new Date(); - const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } }); - if (expired.length === 0) return; - - const expiredIds = expired.map(h => h.id); - for (const hold of expired) { - await this.releaseSeats(hold.seatIds); - } - await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } }); + // Holds are temporary and don't create Journey rows — just delete expired ones. + await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index 9c864c307..a89ecd87d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { StationsService } from './stations.service'; import { CreateStationDto } from './stations.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -10,7 +11,8 @@ export class StationsController { constructor(private service: StationsService) {} @Get() - @ApiOperation({ + @IsPublic() + @ApiOperation({ summary: 'List all stations with country information', description: 'Returns all stations on the Ethio-Djibouti Railway with country codes (ET for Ethiopia, DJ for Djibouti)' }) @@ -48,7 +50,8 @@ export class StationsController { } @Get(':id') - @ApiOperation({ + @IsPublic() + @ApiOperation({ summary: 'Get station details by ID', description: 'Returns station information including name, code, country, coordinates, and facilities' }) diff --git a/apps/edr-passenger-api/src/modules/stations/stations.dto.ts b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts index fc350dbcf..57e78caf6 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.dto.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts @@ -7,8 +7,8 @@ export class CreateStationDto { @ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string; @ApiPropertyOptional() @IsOptional() @IsString() timezone?: string; @ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string; - @ApiProperty({ example: 9.0054 }) @IsNumber() lat: number; - @ApiProperty({ example: 38.7636 }) @IsNumber() lng: number; + @ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number; + @ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number; @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean; } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 5dd1aa77d..bc1faa1bc 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -50,7 +50,10 @@ export class StationsService { } async create(dto: CreateStationDto) { - const station = await this.prisma.station.create({ data: dto }); + const { lat, lng, ...rest } = dto; + const station = await this.prisma.station.create({ + data: { ...rest, ...(lat !== undefined && { lat }), ...(lng !== undefined && { lng }) } as any, + }); await this.auditService.log({ userId: this.request?.user?.id, 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 new file mode 100644 index 000000000..6f2bbb73d --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts @@ -0,0 +1,39 @@ +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('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/system-config/system-config.module.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.module.ts new file mode 100644 index 000000000..f6b71fcd3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { SystemConfigService } from './system-config.service'; +import { SystemConfigController } from './system-config.controller'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule, HttpModule], + controllers: [SystemConfigController], + providers: [SystemConfigService], + exports: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts new file mode 100644 index 000000000..e038bc0f2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; + +export const CONFIG_KEYS = { + SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', + HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', +} as const; + +const DEFAULTS: Record = { + [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', + [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', +}; + +@Injectable() +export class SystemConfigService { + constructor(private prisma: PrismaService) {} + + async getAll(): Promise> { + const rows = await this.prisma.systemConfig.findMany(); + const result: Record = { ...DEFAULTS }; + for (const row of rows) result[row.key] = row.value; + return result; + } + + async getValue(key: string): Promise { + const row = await this.prisma.systemConfig.findUnique({ where: { key } }); + return row?.value ?? DEFAULTS[key] ?? ''; + } + + async getNumber(key: string): Promise { + return parseInt(await this.getValue(key), 10) || parseInt(DEFAULTS[key] ?? '0', 10); + } + + async set(key: string, value: string): Promise { + await this.prisma.systemConfig.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + } + + async updateMany(entries: Record): Promise> { + await Promise.all(Object.entries(entries).map(([k, v]) => this.set(k, v))); + return this.getAll(); + } +} 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.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index cd02974d3..972668e18 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { TicketsController } from './tickets.controller'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ + imports: [NotificationsModule], controllers: [TicketsController], providers: [TicketsService, JwtGuard], exports: [TicketsService, JwtGuard], 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 63b675157..13e31c91a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,5 +1,8 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; import * as QRCode from 'qrcode'; interface OfflineValidation { @@ -12,7 +15,11 @@ interface OfflineValidation { @Injectable() export class TicketsService { - constructor(private prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) { const where: any = {}; @@ -38,47 +45,69 @@ export class TicketsService { end.setDate(end.getDate() + 1); where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; } - const tickets = await this.prisma.ticket.findMany({ - where, - include: { - booking: { - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, - passenger: { include: { user: true } }, + const [tickets, total] = await Promise.all([ + this.prisma.ticket.findMany({ + where, + include: { + booking: { + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + passenger: { select: { id: true, iamUserId: true } }, + }, }, }, - }, - skip: filters.skip, - take: filters.take, - orderBy: { issuedAt: 'desc' }, - }); - const total = await this.prisma.ticket.count({ where }); + skip: filters.skip, + take: filters.take, + orderBy: { issuedAt: 'desc' }, + }), + this.prisma.ticket.count({ where }), + ]); + + const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + return { - items: tickets.map((t) => ({ - id: t.id, - ticketNumber: t.barcodePayload, - bookingRef: t.bookingRef, - booking: { - bookingRef: t.booking.bookingRef, - status: t.booking.status, - bookingType: t.booking.bookingType, - returnLegStatus: (t.booking as any).returnLegStatus ?? null, - outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, - returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, - totalMinor: t.booking.totalMinor, - currency: t.booking.currency, - displayCurrency: t.booking.displayCurrency, - displayTotalMinor: t.booking.displayTotalMinor, - passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail }, - contactEmail: t.booking.contactEmail, - }, - schedule: t.booking.schedule, - seat: t.booking.seats[0]?.seat, - status: t.status, - validatedAt: t.validatedAt, - createdAt: t.issuedAt, - })), + items: tickets.map((t) => { + const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined; + const passengerInfo = iam + ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } + : { fullName: 'Guest', email: t.booking.contactEmail, phone: null }; + return { + id: t.id, + ticketNumber: t.barcodePayload, + bookingRef: t.bookingRef, + booking: { + id: t.booking.id, + bookingRef: t.booking.bookingRef, + status: t.booking.status, + bookingType: t.booking.bookingType, + returnLegStatus: (t.booking as any).returnLegStatus ?? null, + outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, + returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, + totalMinor: t.booking.totalMinor, + currency: t.booking.currency, + displayCurrency: t.booking.displayCurrency, + displayTotalMinor: t.booking.displayTotalMinor, + passenger: passengerInfo, + contactEmail: t.booking.contactEmail, + contactPhone: t.booking.contactPhone, + returnSchedule: (t.booking as any).returnSchedule ?? null, + }, + schedule: t.booking.schedule, + seat: t.booking.seats[0]?.seat, + status: t.status, + validatedAt: t.validatedAt, + createdAt: t.issuedAt, + }; + }), total, skip: filters.skip, take: filters.take, @@ -115,7 +144,7 @@ export class TicketsService { legs: legSummary, }); const qrPayload = await QRCode.toDataURL(qrData); - const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; + const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; const ticket = await this.prisma.ticket.upsert({ where: { bookingId }, @@ -282,6 +311,7 @@ export class TicketsService { } await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); + this.fireBoardingPassNotification(booking, ticket, null); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -299,6 +329,7 @@ export class TicketsService { } if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + this.fireBoardingPassNotification(booking, ticket, resolvedLeg); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -333,6 +364,7 @@ export class TicketsService { else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + this.fireBoardingPassNotification(booking, ticket, resolvedLeg); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -363,6 +395,7 @@ export class TicketsService { if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + this.fireBoardingPassNotification(booking, ticket, resolvedLeg); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -372,9 +405,33 @@ export class TicketsService { } await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); + this.fireBoardingPassNotification(booking, ticket, null); return { validated: true, ticketId: ticket.id, validatedAt: now }; } + /** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */ + private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void { + this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + passenger: { select: { id: true, iamUserId: true } }, + }, + }).then((enriched) => { + if (!enriched) return; + this.notifications.sendBoardingPassNotification({ + passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null, + contactEmail: (enriched as any).contactEmail ?? null, + contactPhone: (enriched as any).contactPhone ?? null, + bookingRef: enriched.bookingRef, + leg, + booking: enriched, + ticket, + }).catch(() => null); + }).catch(() => null); + } + async getValidationLogs(ticketId: string) { return this.prisma.gateValidationLog.findMany({ where: { ticketId }, @@ -387,8 +444,8 @@ export class TicketsService { where: { scheduleId: tripId, status: 'CONFIRMED' }, include: { ticket: true, - seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, - passenger: { include: { user: true } }, + seats: { include: { seat: { include: { coach: true } } } }, + passenger: { select: { id: true, iamUserId: true } }, }, }); diff --git a/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts index 5f5fac19b..8673aa60e 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts @@ -1,21 +1,30 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { DataSource } from 'typeorm'; /** - * Like {@link JwtGuard}, but never rejects the request. + * Like the IAM JwtGuard, but never rejects the request. * - * When a valid `Authorization: Bearer ` is present, `request.user` is - * populated from the JWT strategy (`{ userId, ... }`). When the token is - * missing or invalid, the request still proceeds with `request.user` - * undefined — the handler decides what to do. - * - * Used on `POST /fayda/verification/start`, which must work for both - * logged-in users (who can opt to save the verification to their account) - * and guests (anchored to a booking only). + * When a valid IAM bearer token is present, `request.user` is populated with + * the package `TCurrentUser`. Missing or invalid tokens continue as guests. */ @Injectable() -export class OptionalJwtGuard extends AuthGuard('jwt') { - handleRequest(_err: unknown, user: TUser): TUser { - return (user ?? null) as TUser; +export class OptionalJwtGuard extends IamJwtGuard implements CanActivate { + constructor( + reflector: Reflector, + @InjectDataSource() dataSource: DataSource, + ) { + super(reflector, dataSource); + } + + async canActivate(context: ExecutionContext): Promise { + try { + await super.canActivate(context); + } catch { + context.switchToHttp().getRequest().user = undefined; + } + return true; } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts index f1eb25e8e..b306d2cdc 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -15,6 +15,9 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from './optional-jwt.guard'; import { @@ -25,29 +28,23 @@ import { } from './verifayda.dto'; import { VerifaydaService } from './verifayda.service'; -/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */ -interface AuthedUser { - userId: string; - email?: string; - role?: string; - passengerId?: string; -} - /** Minimal slices of the Express req we touch (avoids a hard dependency on * `@types/express`, which isn't resolved in this package). */ interface RequestWithOptionalUser { - user?: AuthedUser; + user?: TCurrentUser; } interface RequestWithUser { - user: AuthedUser; + user: TCurrentUser; } @ApiTags('Fayda Verification') @Controller('fayda/verification') +@Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class VerifaydaController { constructor(private readonly service: VerifaydaService) {} @Post('start') + @IsPublic() @HttpCode(HttpStatus.OK) @UseGuards(OptionalJwtGuard) @ApiBearerAuth('JWT-auth') @@ -55,8 +52,9 @@ export class VerifaydaController { summary: 'Start a VeriFayda 2.0 verification session', description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to. -- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user; when \`saveToAccount\` is true their account is marked verified on success. -- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified. +- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user. +- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender). +- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT. - The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`, }) @ApiOkResponse({ @@ -73,16 +71,15 @@ export class VerifaydaController { @Req() req: RequestWithOptionalUser, ): Promise<{ authorizationUrl: string }> { const authorizationUrl = await this.service.startVerification({ - purpose: dto.purpose ?? 'PURCHASE', + purpose: dto.purpose ?? 'VERIFY', platform: dto.platform ?? 'WEB', - userId: req.user?.userId, - bookingId: dto.bookingId, - saveToAccount: dto.saveToAccount, + userId: req.user?.id, }); return { authorizationUrl }; } @Get('complete') + @IsPublic() @ApiOperation({ summary: 'Complete a verification (Fayda redirect / client callback lands here)', description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``, @@ -106,6 +103,6 @@ export class VerifaydaController { async status( @Req() req: RequestWithUser, ): Promise { - return this.service.getVerificationStatus(req.user.userId); + return this.service.getVerificationStatus(req.user.id); } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 005a3e517..f446b6fb3 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -1,31 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsOptional, IsString } from 'class-validator'; export class StartVerificationDto { @ApiPropertyOptional({ - enum: ['LOGIN', 'PURCHASE'], - default: 'PURCHASE', - description: 'Reason for verification.', - }) - @IsOptional() - @IsIn(['LOGIN', 'PURCHASE']) - purpose?: 'LOGIN' | 'PURCHASE'; - - @ApiPropertyOptional({ + enum: ['LOGIN', 'VERIFY'], + default: 'VERIFY', description: - 'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.', + 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.', }) @IsOptional() - @IsString() - bookingId?: string; - - @ApiPropertyOptional({ - description: - 'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.', - }) - @IsOptional() - @IsBoolean() - saveToAccount?: boolean; + @IsIn(['LOGIN', 'VERIFY']) + purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ enum: ['WEB', 'MOBILE'], @@ -39,8 +24,8 @@ export class StartVerificationDto { } export class CompleteVerificationResultDto { - @ApiProperty({ enum: ['LOGIN', 'PURCHASE'] }) - purpose: 'LOGIN' | 'PURCHASE'; + @ApiProperty({ enum: ['LOGIN', 'VERIFY'] }) + purpose: 'LOGIN' | 'VERIFY'; @ApiProperty() verified: boolean; @@ -58,10 +43,22 @@ export class CompleteVerificationResultDto { agentId?: string; }; - @ApiPropertyOptional({ - description: 'Verified full name from Fayda (PURCHASE flow).', - }) + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; + + @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' }) + email?: string; + + @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' }) + phoneNumber?: string; + + @ApiPropertyOptional({ + description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).', + }) + birthdate?: string; + + @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) + gender?: string; } export class VerifaydaCallbackDto { diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts index d850b1dbf..e54b94726 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts @@ -2,12 +2,9 @@ import { Module } from '@nestjs/common'; import { VerifaydaController } from './verifayda.controller'; import { VerifaydaService } from './verifayda.service'; import { PrismaModule } from '../../common/prisma.module'; -import { AuthModule } from '../auth/auth.module'; @Module({ - // AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry - // config as /auth/login) to mint tokens for the LOGIN flow. - imports: [PrismaModule, AuthModule], + imports: [PrismaModule], controllers: [VerifaydaController], providers: [VerifaydaService], exports: [VerifaydaService], diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts index e4b8cb790..ede9aa9e8 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -1,5 +1,4 @@ import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; import { exportJWK, generateKeyPair, type JWK } from 'jose'; import { PrismaService } from '../../common/prisma.service'; import { FaydaConfig } from '../../config/fayda.config'; @@ -16,12 +15,6 @@ function buildPrismaMock() { bookingSeat: { updateMany: jest.fn(), }, - user: { - findUnique: jest.fn(), - findFirst: jest.fn(), - create: jest.fn(), - update: jest.fn(), - }, passenger: { create: jest.fn() }, loyaltyAccount: { create: jest.fn() }, walletAccount: { create: jest.fn() }, @@ -30,10 +23,8 @@ function buildPrismaMock() { }; } -function buildJwtMock(): jest.Mocked { - return { - sign: jest.fn(() => 'signed.jwt.token'), - } as unknown as jest.Mocked; +function buildDataSourceMock() { + return { query: jest.fn().mockResolvedValue([]) }; } function buildConfig(overrides?: Partial): FaydaConfig { @@ -65,7 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked { let prisma: ReturnType; - let jwt: jest.Mocked; + let dataSource: ReturnType; let service: VerifaydaService; let realPrivateJwk: JWK; @@ -77,12 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { beforeEach(() => { prisma = buildPrismaMock(); - jwt = buildJwtMock(); + dataSource = buildDataSourceMock(); const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] }); service = new VerifaydaService( buildConfigService(cfg), prisma as unknown as PrismaService, - jwt, + dataSource as any, ); (global as any).fetch = jest.fn(); }); @@ -96,13 +87,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { prisma.faydaVerificationSession.create.mockResolvedValue({}); const url = await service.startVerification({ - purpose: 'PURCHASE', + purpose: 'VERIFY', userId: 'user-1', - saveToAccount: true, }); const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data; - expect(created.purpose).toBe('PURCHASE'); + expect(created.purpose).toBe('VERIFY'); expect(created.platform).toBe('WEB'); expect(typeof created.state).toBe('string'); expect(typeof created.codeVerifier).toBe('string'); @@ -136,10 +126,10 @@ describe('VerifaydaService (OIDC, client-callback)', () => { const disabledService = new VerifaydaService( buildConfigService(buildConfig({ enabled: false })), prisma as unknown as PrismaService, - jwt, + buildDataSourceMock() as any, ); await expect( - disabledService.startVerification({ purpose: 'PURCHASE' }), + disabledService.startVerification({ purpose: 'VERIFY' }), ).rejects.toMatchObject({ status: 503 }); }); }); @@ -150,14 +140,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => { id: 'session-1', state: 'state-abc', codeVerifier: 'verifier-xyz', - purpose: 'PURCHASE', + purpose: 'VERIFY', platform: 'WEB', - saveToAccount: false, status: 'PENDING', errorCode: null, errorDescription: null, - userId: null, - bookingId: null, + iamUserId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -209,18 +197,16 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); }); - describe('completeVerification — PURCHASE', () => { + describe('completeVerification — VERIFY', () => { function pendingSession(overrides: Partial = {}) { return { id: 'session-1', state: 'state-abc', codeVerifier: 'verifier-xyz', - purpose: 'PURCHASE', + purpose: 'VERIFY', platform: 'WEB', - saveToAccount: false, status: 'PENDING', - userId: null, - bookingId: null, + iamUserId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -238,19 +224,23 @@ describe('VerifaydaService (OIDC, client-callback)', () => { (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); } - it('stamps the booking seats and returns { verified, fullName }', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ bookingId: 'booking-1' }), - ); + it('returns the verified identity attributes and writes no domain rows', async () => { + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); prisma.faydaVerificationSession.update.mockResolvedValue({}); - prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); mockFetchSequence( { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, { headers: new Headers({ 'content-type': 'application/json' }), text: async () => - JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }), + JSON.stringify({ + sub: 'fayda-sub-1', + name: 'Test User', + email: 'test@example.com', + phone_number: '+251911000000', + birthdate: '1990-05-01', + gender: 'Male', + }), }, ); @@ -260,66 +250,17 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); expect(result).toMatchObject({ - purpose: 'PURCHASE', + purpose: 'VERIFY', verified: true, fullName: 'Test User', + email: 'test@example.com', + phoneNumber: '+251911000000', + birthdate: '1990-05-01', + gender: 'Male', }); expect(result.token).toBeUndefined(); - expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({ - where: { bookingId: 'booking-1' }, - data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }), - }); - }); - - it('saves to the User account when saveToAccount=true and no conflict', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ userId: 'user-1', saveToAccount: true }), - ); - prisma.user.findFirst.mockResolvedValue(null); - prisma.user.update.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockFetchSequence( - { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, - { - headers: new Headers({ 'content-type': 'application/json' }), - text: async () => - JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }), - }, - ); - - const result = await service.completeVerification({ - code: 'authcode', - state: 'state-abc', - }); - - expect(result.verified).toBe(true); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }), - }); - }); - - it('throws identity_conflict (409) when faydaSub belongs to another user', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ userId: 'user-1', saveToAccount: true }), - ); - prisma.user.findFirst.mockResolvedValue({ id: 'other-user' }); - prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); - - mockFetchSequence( - { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, - { - headers: new Headers({ 'content-type': 'application/json' }), - text: async () => - JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }), - }, - ); - - await expect( - service.completeVerification({ code: 'authcode', state: 'state-abc' }), - ).rejects.toMatchObject({ status: 409 }); - expect(prisma.user.update).not.toHaveBeenCalled(); + expect(result.user).toBeUndefined(); + expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled(); }); it('throws 502 when the token endpoint returns 4xx', async () => { @@ -353,11 +294,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); it('falls back to localized name (name#en) when name is missing', async () => { - prisma.faydaVerificationSession.findUnique.mockResolvedValue( - pendingSession({ bookingId: 'booking-2' }), - ); + prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession()); prisma.faydaVerificationSession.update.mockResolvedValue({}); - prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 }); mockFetchSequence( { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) }, @@ -377,9 +315,6 @@ describe('VerifaydaService (OIDC, client-callback)', () => { state: 'state-abc', }); expect(result.fullName).toBe('English Name'); - expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe( - 'English Name', - ); }); }); @@ -391,10 +326,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => { codeVerifier: 'verifier-xyz', purpose: 'LOGIN', platform: 'WEB', - saveToAccount: false, status: 'PENDING', - userId: null, - bookingId: null, + iamUserId: null, expiresAt: new Date(Date.now() + 60_000), ...overrides, }; @@ -420,139 +353,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => { (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift())); } - /** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */ - function mockUserFindUnique(bySub: any, fullUser: any) { - prisma.user.findUnique.mockImplementation(async (args: any) => { - if (args?.where?.faydaSub !== undefined) return bySub; - if (args?.where?.id !== undefined) return fullUser; - return null; - }); - } - beforeEach(() => { prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession()); }); - it('creates a new user when no match and returns { token, user }', async () => { - const fullUser = { - id: 'new-user', - email: 'new@example.com', - role: 'PASSENGER', - passenger: { id: 'p-new' }, - agent: null, - }; - mockUserFindUnique(null, fullUser); - prisma.user.findFirst.mockResolvedValue(null); - prisma.user.create.mockResolvedValue({ id: 'new-user' }); - prisma.passenger.create.mockResolvedValue({ id: 'p-new' }); - prisma.loyaltyAccount.create.mockResolvedValue({}); - prisma.walletAccount.create.mockResolvedValue({}); - prisma.userPreferences.create.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - + it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', async () => { mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' }); - - const result = await service.completeVerification({ - code: 'c', - state: 'state-login', - }); - - expect(result).toMatchObject({ - purpose: 'LOGIN', - verified: true, - token: 'signed.jwt.token', - user: { id: 'new-user', passengerId: 'p-new' }, - }); - expect(prisma.user.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - faydaSub: 'login-sub-1', - faydaVerified: true, - email: 'new@example.com', - }), - }), - ); - expect(prisma.passenger.create).toHaveBeenCalled(); - expect(jwt.sign).toHaveBeenCalledWith( - expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }), - ); - }); - - it('logs in an existing user already linked by faydaSub', async () => { - const fullUser = { - id: 'known-user', - email: 'k@example.com', - role: 'PASSENGER', - passenger: { id: 'p-k' }, - agent: null, - }; - mockUserFindUnique({ id: 'known-user' }, fullUser); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockLoginFetch({ sub: 'login-sub-2', name: 'Known' }); - - const result = await service.completeVerification({ - code: 'c', - state: 'state-login', - }); - - expect(result.user?.id).toBe('known-user'); - expect(prisma.user.create).not.toHaveBeenCalled(); - }); - - it('links Fayda to an existing account matched by email', async () => { - const fullUser = { - id: 'acc-1', - email: 'match@example.com', - role: 'PASSENGER', - passenger: { id: 'p-1' }, - agent: null, - }; - mockUserFindUnique(null, fullUser); - prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null }); - prisma.user.update.mockResolvedValue({}); - prisma.faydaVerificationSession.update.mockResolvedValue({}); - - mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' }); - - const result = await service.completeVerification({ - code: 'c', - state: 'state-login', - }); - - expect(result.user?.id).toBe('acc-1'); - expect(prisma.user.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'acc-1' }, - data: expect.objectContaining({ faydaSub: 'login-sub-3' }), - }), - ); - expect(prisma.user.create).not.toHaveBeenCalled(); - }); - - it('throws identity_conflict (409) when matched account has a different faydaSub', async () => { - mockUserFindUnique(null, null); - prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' }); prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); - mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' }); - await expect( service.completeVerification({ code: 'c', state: 'state-login' }), - ).rejects.toMatchObject({ status: 409 }); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(prisma.user.create).not.toHaveBeenCalled(); + ).rejects.toMatchObject({ + status: 401, + response: expect.objectContaining({ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM' }), + }); + }); + + it('does not touch the database for LOGIN purpose', async () => { + mockLoginFetch({ sub: 'login-sub-2', name: 'Person' }); + prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 }); + + await expect( + service.completeVerification({ code: 'c', state: 'state-login' }), + ).rejects.toMatchObject({ status: 401 }); + expect(dataSource.query).not.toHaveBeenCalled(); + expect(prisma.passenger.create).not.toHaveBeenCalled(); }); }); describe('getVerificationStatus', () => { - it('returns verified=true when User row has the flag', async () => { - prisma.user.findUnique.mockResolvedValue({ - faydaVerified: true, - faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'), - fullName: 'Test User', - }); - const result = await service.getVerificationStatus('user-1'); + it('returns verified=true when IAM user metadata has the flag', async () => { + dataSource.query.mockResolvedValueOnce([{ + metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' }, + name: { en: 'Test User', am: 'ቴስት ዩዘር' }, + }]); + const result = await service.getVerificationStatus('iam-user-1'); expect(result).toEqual({ verified: true, verifiedAt: new Date('2026-01-01T00:00:00Z'), @@ -560,9 +395,9 @@ describe('VerifaydaService (OIDC, client-callback)', () => { }); }); - it('returns verified=false when User row is missing or unverified', async () => { - prisma.user.findUnique.mockResolvedValue(null); - const result = await service.getVerificationStatus('user-x'); + it('returns verified=false when IAM user is missing or unverified', async () => { + dataSource.query.mockResolvedValueOnce([]); + const result = await service.getVerificationStatus('iam-user-x'); expect(result).toEqual({ verified: false }); }); }); diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index f7b3e77fb..795a6f158 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -6,10 +6,9 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import axios, { AxiosInstance } from 'axios'; -import * as bcrypt from 'bcrypt'; -import { randomBytes } from 'crypto'; import { PrismaService } from '../../common/prisma.service'; import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; import { @@ -20,7 +19,6 @@ import { import { generateClientAssertion } from './utils/client-assertion.util'; import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; import { - FaydaIdentityConflictException, FaydaTokenExchangeException, FaydaUserInfoException, } from './verifayda.errors'; @@ -48,9 +46,7 @@ export interface VerifaydaVerificationResult { export interface StartVerificationInput { purpose: VerifaydaPurpose; platform?: FaydaPlatform; - userId?: string; - bookingId?: string; - saveToAccount?: boolean; + userId?: string; // iamUserId of the authenticated user, if any } export interface FaydaUserSummary { @@ -63,7 +59,8 @@ export interface FaydaUserSummary { /** * Result of completing a verification. `verified` is always true on success. - * LOGIN additionally returns a JWT + user; PURCHASE returns the verified name. + * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity + * attributes (name, email, phone, dob, gender) for the caller to consume. */ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; @@ -71,6 +68,10 @@ export interface CompleteVerificationResult { token?: string; user?: FaydaUserSummary; fullName?: string; + email?: string; + phoneNumber?: string; + birthdate?: string; + gender?: string; } @Injectable() @@ -88,7 +89,7 @@ export class VerifaydaService { constructor( private readonly config: ConfigService, private readonly prisma: PrismaService, - private readonly jwt: JwtService, + @InjectDataSource() private readonly dataSource: DataSource, ) { const fayda = this.config.get('fayda'); if (!fayda) { @@ -143,15 +144,13 @@ export class VerifaydaService { codeVerifier, purpose: input.purpose, platform: input.platform ?? 'WEB', - saveToAccount: input.saveToAccount ?? false, - userId: input.userId ?? null, - bookingId: input.bookingId ?? null, + iamUserId: input.userId ?? null, expiresAt, }, }); this.logger.log( - `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`, + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, ); return this.buildAuthorizationUrl({ state, codeChallenge }); @@ -215,17 +214,22 @@ export class VerifaydaService { } let result: CompleteVerificationResult; - if (session.purpose === 'PURCHASE') { - await this.handlePurchaseSuccess(session, normalized); - result = { - purpose: 'PURCHASE', - verified: true, - fullName: normalized.fullName, - }; - } else { + if (session.purpose === 'LOGIN') { const { userId } = await this.handleLoginSuccess(normalized); const login = await this.issueLoginToken(userId); result = { purpose: 'LOGIN', verified: true, ...login }; + } else { + // VERIFY — prove identity and hand the verified attributes back to the + // caller. No domain writes; the session row tracks status as usual. + result = { + purpose: 'VERIFY', + verified: true, + fullName: normalized.fullName, + email: normalized.email, + phoneNumber: normalized.phoneNumber, + birthdate: normalized.birthdate, + gender: normalized.gender, + }; } await this.prisma.faydaVerificationSession.update({ @@ -251,52 +255,25 @@ export class VerifaydaService { } } - /** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */ private async issueLoginToken( - userId: string, + _userId: string, ): Promise<{ token: string; user: FaydaUserSummary }> { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - include: { passenger: true, agent: true }, + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package auth endpoints.', }); - if (!user) { - // Should not happen — we just resolved/created this user. - throw new UnauthorizedException({ - code: 'FAYDA_LOGIN_FAILED', - message: 'Could not load the verified user', - }); - } - - const summary: FaydaUserSummary = { - id: user.id, - email: user.email, - role: user.role, - passengerId: user.passenger?.id, - agentId: user.agent?.id, - }; - const token = this.jwt.sign({ - sub: summary.id, - email: summary.email, - role: summary.role, - passengerId: summary.passengerId, - agentId: summary.agentId, - }); - - this.logger.log(`Fayda login issued token for user ${user.id}`); - return { token, user: summary }; } - async getVerificationStatus(userId: string): Promise { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true }, - }); - - return { - verified: user?.faydaVerified ?? false, - verifiedAt: user?.faydaVerifiedAt ?? undefined, - fullName: user?.fullName ?? undefined, - }; + async getVerificationStatus(iamUserId: string): Promise { + const rows = await this.dataSource.query<{ metadata: Record | null; name: { en: string; am: string } | null }[]>( + `SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + const iam = rows[0] ?? null; + const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; + const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined; + const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; + return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; } // ========================================================================== @@ -422,149 +399,16 @@ export class VerifaydaService { }; } - private async handlePurchaseSuccess( - session: { - id: string; - userId: string | null; - bookingId: string | null; - saveToAccount: boolean; - }, - normalized: NormalizedFaydaUserInfo, - ): Promise { - if (session.bookingId) { - await this.prisma.bookingSeat.updateMany({ - where: { bookingId: session.bookingId }, - data: { - faydaVerifiedAt: new Date(), - faydaSub: normalized.sub, - faydaVerifiedName: normalized.fullName ?? null, - }, - }); - } - - if (session.userId && session.saveToAccount) { - const conflict = await this.prisma.user.findFirst({ - where: { - faydaSub: normalized.sub, - NOT: { id: session.userId }, - }, - select: { id: true }, - }); - if (conflict) { - throw new FaydaIdentityConflictException(); - } - - await this.prisma.user.update({ - where: { id: session.userId }, - data: { - faydaVerified: true, - faydaVerifiedAt: new Date(), - faydaSub: normalized.sub, - }, - }); - } - } - - /** - * Resolves the User for a LOGIN flow and returns its id (the caller mints the - * JWT via {@link issueLoginToken}). Resolution order: - * 1. Existing user already linked to this Fayda `sub`. - * 2. Existing account whose email/phone matches — linked to this `sub`. - * 3. Otherwise a fresh Fayda-backed account is created. - */ + // LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow. + // This method is kept as a stub so completeVerification() still compiles; + // it throws immediately without touching the database. private async handleLoginSuccess( - normalized: NormalizedFaydaUserInfo, + _normalized: NormalizedFaydaUserInfo, ): Promise<{ userId: string }> { - let userId: string; - - const bySub = await this.prisma.user.findUnique({ - where: { faydaSub: normalized.sub }, - select: { id: true }, + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.', }); - - if (bySub) { - userId = bySub.id; - } else { - const matchers: Array<{ email?: string; phone?: string }> = []; - if (normalized.email) matchers.push({ email: normalized.email }); - if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber }); - - const existing = matchers.length - ? await this.prisma.user.findFirst({ - where: { OR: matchers }, - select: { id: true, faydaSub: true }, - }) - : null; - - if (existing) { - if (existing.faydaSub && existing.faydaSub !== normalized.sub) { - // The matched account is already tied to a different Fayda identity. - throw new FaydaIdentityConflictException(); - } - await this.prisma.user.update({ - where: { id: existing.id }, - data: { - faydaSub: normalized.sub, - faydaVerified: true, - faydaVerifiedAt: new Date(), - }, - }); - userId = existing.id; - this.logger.log(`Fayda login linked existing user ${existing.id}`); - } else { - userId = await this.createFaydaUser(normalized); - this.logger.log(`Fayda login created new user ${userId}`); - } - } - - return { userId }; - } - - /** - * Creates a Fayda-backed User plus the same satellite rows registration makes - * (Passenger, LoyaltyAccount, WalletAccount, UserPreferences). - * - * The user has no password — `passwordHash` is set to a bcrypt of random bytes - * so password login is impossible; they authenticate only via Fayda. When - * Fayda doesn't supply an email/phone, a deterministic placeholder derived from - * the (unique) `sub` keeps the NOT NULL + unique columns satisfied. - */ - private async createFaydaUser( - normalized: NormalizedFaydaUserInfo, - ): Promise { - const passwordHash = await bcrypt.hash( - randomBytes(32).toString('hex'), - 10, - ); - const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`; - const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`; - const fullName = normalized.fullName ?? 'Fayda User'; - - const user = await this.prisma.user.create({ - data: { - fullName, - email, - phone, - passwordHash, - faydaVerified: true, - faydaVerifiedAt: new Date(), - faydaSub: normalized.sub, - }, - select: { id: true }, - }); - const passenger = await this.prisma.passenger.create({ - data: { userId: user.id }, - select: { id: true }, - }); - await this.prisma.loyaltyAccount.create({ - data: { passengerId: passenger.id }, - }); - await this.prisma.walletAccount.create({ - data: { passengerId: passenger.id }, - }); - await this.prisma.userPreferences.create({ data: { userId: user.id } }); - - return user.id; } private async markSessionFailed( @@ -585,7 +429,6 @@ export class VerifaydaService { } private classifyFailureReason(err: unknown): string { - if (err instanceof FaydaIdentityConflictException) return 'identity_conflict'; if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; return 'verification_failed'; @@ -602,9 +445,8 @@ export class VerifaydaService { ): Promise { this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`); - if (this.stubEnabled != false || this.stubEnabled) { - this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)'); - // In development mode, return mock verified data + if (!this.stubEnabled) { + this.logger.warn('Verifayda not configured — returning mock data (development mode)'); return { verified: true, passengerData: { diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts index 7c7335c34..450bfaff5 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts @@ -1,4 +1,4 @@ -export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE'; +export type VerifaydaPurpose = 'LOGIN' | 'VERIFY'; export interface FaydaTokenResponse { access_token: string; diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts index 8d100863c..1ecb2edea 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { WalletService } from './wallet.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -7,6 +8,7 @@ import { JwtGuard } from '../../common/jwt.guard'; @Controller('wallet') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') +@Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class WalletController { constructor(private service: WalletService) {} @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } diff --git a/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts new file mode 100644 index 000000000..1bade05ae --- /dev/null +++ b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + Application, + Organization, + OrganizationConfiguration, + Permission, + Role, + RolePermission, +} from '@tria-plc/iamapi-common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { ERoleKey } from '@tria-plc/api-common/utils/enums/seed.enum'; +import { + PASSENGER_PERMISSIONS, + PASSENGER_PERMISSION_KEYS, +} from './passenger-permissions.registry'; +import { EDR_PASSENGER_APPLICATION, EDR_PASSENGER_ROLES, type PassengerSeedRole } from './edr-passenger.seed'; + +const EDR_ORG_KEY = 'edr'; +const EDR_ORG_NAME = { am: 'EDR', en: 'EDR' }; +const SEED_FLAG = 'SEED_EDR_PASSENGER_ORG'; + +type SeedOrganization = { id: string; key: string }; + +@Injectable() +export class EdrPassengerOrgSeeder { + private readonly logger = new Logger(EdrPassengerOrgSeeder.name); + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping passenger org seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + await this.ensureApplication(manager); + await this.ensurePermissions(manager); + const organization = await this.ensureOrganization(manager); + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensureRoles(manager, EDR_PASSENGER_ROLES); + await this.ensureRolePermissions(manager, EDR_PASSENGER_ROLES); + await this.ensureSuperAdminPermissions(manager); + }); + + this.logger.log(`Ensured EDR passenger organization seed for '${EDR_ORG_KEY}'`); + } + + private async ensureApplication(manager: EntityManager) { + await manager.getRepository(Application).upsert( + { + id: EDR_PASSENGER_APPLICATION.id, + key: EDR_PASSENGER_APPLICATION.key, + name: EDR_PASSENGER_APPLICATION.name, + }, + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured application '${EDR_PASSENGER_APPLICATION.key}'`); + } + + private async ensurePermissions(manager: EntityManager) { + await manager.getRepository(Permission).upsert( + PASSENGER_PERMISSIONS.map((p) => ({ + id: p.id, + key: p.key, + name: p.name, + applicationId: EDR_PASSENGER_APPLICATION.id, + })), + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured ${PASSENGER_PERMISSIONS.length} passenger permissions`); + } + + private async ensureOrganization(manager: EntityManager): Promise { + const repo = manager.getRepository(Organization); + let org = await repo.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true } }); + + if (!org) { + const result = await repo.insert({ + key: EDR_ORG_KEY, + name: EDR_ORG_NAME, + isGovernmentOrganization: true, + }); + this.logger.log(`Seeded EDR passenger organization '${EDR_ORG_KEY}'`); + return { id: result.identifiers[0]?.id as string, key: EDR_ORG_KEY }; + } + + this.logger.log(`Ensured EDR passenger organization '${EDR_ORG_KEY}'`); + return { id: org.id as string, key: EDR_ORG_KEY }; + } + + private async ensureOrganizationConfiguration(manager: EntityManager, organizationId: string) { + await manager.getRepository(OrganizationConfiguration).upsert( + { organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true }, + { conflictPaths: { organizationId: true } }, + ); + this.logger.log(`Ensured organization configuration for '${EDR_ORG_KEY}'`); + } + + private async ensureRoles(manager: EntityManager, seedRoles: PassengerSeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured passenger roles: ${seedRoles.map((r) => r.key).join(', ')}`); + } + + private async ensureRolePermissions(manager: EntityManager, seedRoles: PassengerSeedRole[]) { + const allPermissionKeys = [...new Set(seedRoles.flatMap((r) => r.permissionKeys))]; + if (!allPermissionKeys.length) return; + + const roles = await manager.getRepository(Role).find({ + where: { key: In(seedRoles.map((r) => r.key)) }, + select: { id: true, key: true }, + }); + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(allPermissionKeys) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permByKey = new Map(permissions.map((p) => [p.key, p])); + + const links = seedRoles.flatMap((seedRole) => { + const role = roleByKey.get(seedRole.key); + if (!role) throw new Error(`missing_role:${seedRole.key}`); + + return seedRole.permissionKeys.map((key) => { + const perm = permByKey.get(key); + if (!perm) throw new Error(`missing_permission:${key}`); + return { roleId: role.id, permissionId: perm.id }; + }); + }); + + await manager.getRepository(RolePermission).upsert(links, { + conflictPaths: { roleId: true, permissionId: true }, + }); + this.logger.log(`Ensured ${links.length} passenger role-permission links`); + } + + private async ensureSuperAdminPermissions(manager: EntityManager) { + const role = await manager.getRepository(Role).findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + if (!role) { + this.logger.warn(`Role ${ERoleKey.SUPER_ADMIN} not found; skipping super_admin permission links`); + return; + } + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(PASSENGER_PERMISSION_KEYS) }, + select: { id: true, key: true }, + }); + + if (!permissions.length) return; + + await manager.getRepository(RolePermission).upsert( + permissions.map((p) => ({ roleId: role.id, permissionId: p.id })), + { conflictPaths: { roleId: true, permissionId: true } }, + ); + this.logger.log(`Ensured ${permissions.length} passenger permissions on super_admin`); + } +} diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts new file mode 100644 index 000000000..8413cadb2 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -0,0 +1,47 @@ +import { + PASSENGER_PERMISSIONS, + PASSENGER_PERMISSION_KEYS, + ROLE_PERMISSION_PRESETS, +} from './passenger-permissions.registry'; + +export type PassengerSeedRole = { + key: string; + name: { en: string }; + permissionKeys: string[]; +}; + +export const EDR_PASSENGER_APPLICATION = { + id: 'd2000001-0001-4000-8000-000000000001', + key: 'edr_passenger_app', + name: { + am: 'EDR Passenger App', + en: 'EDR Passenger App', + }, +} as const; + +export const EDR_PASSENGER_PERMISSIONS = [...PASSENGER_PERMISSIONS]; + +export { PASSENGER_PERMISSION_KEYS } from './passenger-permissions.registry'; + +export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [ + { + key: 'edr_passenger_backoffice_admin', + name: { en: 'EDR Passenger Backoffice Admin' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeAdmin], + }, + { + key: 'edr_passenger_backoffice_staff', + name: { en: 'EDR Passenger Backoffice Staff' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeStaff], + }, + { + key: 'edr_passenger_agent', + name: { en: 'EDR Passenger Agent' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.agent], + }, + { + key: 'edr_passenger_finance', + name: { en: 'EDR Passenger Finance' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + }, +]; diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts new file mode 100644 index 000000000..9b06c812b --- /dev/null +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -0,0 +1,121 @@ +const APP_KEY = 'edr_passenger_app'; + +export type PassengerPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +const perm = (id: string, key: string, en: string): PassengerPermissionSeed => ({ + id, + key, + name: { am: en, en }, + applicationKey: APP_KEY, +}); + +export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_passenger_app:bookings:view', 'View bookings'), + perm('c1000001-0001-4000-8000-000000000002', 'edr_passenger_app:bookings:manage', 'Manage bookings'), + perm('c1000001-0001-4000-8000-000000000003', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), + perm('c1000001-0001-4000-8000-000000000004', 'edr_passenger_app:passengers:view', 'View passengers'), + perm('c1000001-0001-4000-8000-000000000005', 'edr_passenger_app:passengers:manage', 'Manage passengers'), + perm('c1000001-0001-4000-8000-000000000006', 'edr_passenger_app:tickets:view', 'View tickets'), + perm('c1000001-0001-4000-8000-000000000007', 'edr_passenger_app:tickets:manage', 'Manage tickets'), + perm('c1000001-0001-4000-8000-000000000008', 'edr_passenger_app:payments:view_all', 'View all payments'), + perm('c1000001-0001-4000-8000-000000000009', 'edr_passenger_app:payments:refund', 'Refund payments'), + perm('c1000001-0001-4000-8000-00000000000a', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), + perm('c1000001-0001-4000-8000-00000000000b', 'edr_passenger_app:reports:view', 'View reports'), + perm('c1000001-0001-4000-8000-00000000000c', 'edr_passenger_app:fraud:view', 'View fraud alerts'), + perm('c1000001-0001-4000-8000-00000000000d', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'), + perm('c1000001-0001-4000-8000-00000000000e', 'edr_passenger_app:audit:view', 'View audit logs'), + perm('c1000001-0001-4000-8000-00000000000f', 'edr_passenger_app:agents:view', 'View agents'), + perm('c1000001-0001-4000-8000-000000000010', 'edr_passenger_app:agents:manage', 'Manage agents'), + perm('c1000001-0001-4000-8000-000000000011', 'edr_passenger_app:currencies:manage', 'Manage currencies'), + perm('c1000001-0001-4000-8000-000000000012', 'edr_passenger_app:notifications:send', 'Send notifications'), + perm('c1000001-0001-4000-8000-000000000013', 'edr_passenger_app:dashboard:view', 'View dashboard'), + perm('c1000001-0001-4000-8000-000000000014', 'edr_passenger_app:admin', 'Full admin access'), +]; + +export const PASSENGER_PERMISSION_KEYS = PASSENGER_PERMISSIONS.map((p) => p.key); + +export const PASSENGER_PERMS = { + bookings: { + view: 'edr_passenger_app:bookings:view', + manage: 'edr_passenger_app:bookings:manage', + cancel: 'edr_passenger_app:bookings:cancel', + }, + passengers: { + view: 'edr_passenger_app:passengers:view', + manage: 'edr_passenger_app:passengers:manage', + }, + tickets: { + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + }, + payments: { + viewAll: 'edr_passenger_app:payments:view_all', + refund: 'edr_passenger_app:payments:refund', + manageMethods: 'edr_passenger_app:payments:manage_methods', + }, + reports: { + view: 'edr_passenger_app:reports:view', + }, + fraud: { + view: 'edr_passenger_app:fraud:view', + manage: 'edr_passenger_app:fraud:manage', + }, + audit: { + view: 'edr_passenger_app:audit:view', + }, + agents: { + view: 'edr_passenger_app:agents:view', + manage: 'edr_passenger_app:agents:manage', + }, + currencies: { + manage: 'edr_passenger_app:currencies:manage', + }, + notifications: { + send: 'edr_passenger_app:notifications:send', + }, + dashboard: { + view: 'edr_passenger_app:dashboard:view', + }, + admin: 'edr_passenger_app:admin', +} as const; + +export const ROLE_PERMISSION_PRESETS = { + backofficeAdmin: [...PASSENGER_PERMISSION_KEYS], + + backofficeStaff: [ + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.bookings.cancel, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.passengers.manage, + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.payments.viewAll, + PASSENGER_PERMS.reports.view, + PASSENGER_PERMS.dashboard.view, + PASSENGER_PERMS.notifications.send, + PASSENGER_PERMS.agents.view, + PASSENGER_PERMS.fraud.view, + PASSENGER_PERMS.audit.view, + ], + + agent: [ + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.payments.refund, + ], + + finance: [ + PASSENGER_PERMS.payments.viewAll, + PASSENGER_PERMS.payments.refund, + PASSENGER_PERMS.reports.view, + PASSENGER_PERMS.dashboard.view, + ], +} as const; diff --git a/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts new file mode 100644 index 000000000..9e5ef560e --- /dev/null +++ b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +const SEED_FLAG = 'SEED_PASSENGER_STAFF'; +const EDR_ORG_KEY = 'edr'; + +const STAFF_USERS = [ + { email: 'passenger.admin@edr.local', username: 'passenger_admin', roleKey: 'edr_passenger_backoffice_admin' }, + { email: 'passenger.staff@edr.local', username: 'passenger_staff', roleKey: 'edr_passenger_backoffice_staff' }, + { email: 'passenger.agent@edr.local', username: 'passenger_agent', roleKey: 'edr_passenger_agent' }, + { email: 'passenger.finance@edr.local', username: 'passenger_finance', roleKey: 'edr_passenger_finance' }, +] as const; + +@Injectable() +export class PassengerStaffUsersSeeder { + private readonly logger = new Logger(PassengerStaffUsersSeeder.name); + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping passenger staff seed because ${SEED_FLAG} is not enabled`); + return; + } + + const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) throw new Error(`missing_organization:${EDR_ORG_KEY}`); + + const hashedPassword = await hashPassword(password); + + for (const staff of STAFF_USERS) { + const role = await manager.getRepository(Role).findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + if (!role) throw new Error(`missing_role:${staff.roleKey}`); + + let user = await manager.getRepository(User).findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded passenger staff user ${staff.email}`); + } + + const credentialExists = await manager.getRepository(UserCredential).exists({ + where: { userId: user.id, isActive: true }, + }); + if (!credentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await manager.getRepository(UserRole).upsert( + { userId: user.id, roleId: role.id, organizationId: organization.id }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const employeeExists = await manager.getRepository(Employee).exists({ + where: { userId: user.id, organizationId: organization.id, isCurrent: true }, + }); + if (!employeeExists) { + await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + }); + + this.logger.log('Ensured passenger staff users'); + } +} diff --git a/apps/edr-passenger-api/tsconfig.json b/apps/edr-passenger-api/tsconfig.json index e9fbe1ffe..49158fb15 100644 --- a/apps/edr-passenger-api/tsconfig.json +++ b/apps/edr-passenger-api/tsconfig.json @@ -8,6 +8,8 @@ "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", "paths": { "@/*": ["./src/*"] }, + "module": "node16", + "moduleResolution": "node16", "strictPropertyInitialization": false, "noUnusedLocals": false, "noUnusedParameters": false diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index d220bdd1a..efb62c0c3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -2,15 +2,30 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, Edit, DollarSign, Clock } from 'lucide-react'; +import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Badge from '@/components/ui/Badge'; +import Modal from '@/components/ui/Modal'; import { agentsApi } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + export default function AgentsPage() { const [filters, setFilters] = useState({ search: '', active: '' }); + const [selected, setSelected] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['agents', filters], @@ -30,7 +45,7 @@ export default function AgentsPage() { render: (agent: any) => (
{agent.user?.fullName || 'N/A'}
-
{agent.user?.email}
+
{agent.user?.email}
), }, @@ -51,19 +66,21 @@ export default function AgentsPage() { ]; const actions = [ + { + label: 'View Details', + onClick: (agent: any) => setSelected(agent), + variant: 'secondary' as const, + icon: Eye, + }, { label: 'View Shifts', - onClick: (agent: any) => { - window.location.href = `/agents/${agent.id}/shifts`; - }, + onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; }, variant: 'secondary' as const, icon: Clock, }, { label: 'View Commissions', - onClick: (agent: any) => { - window.location.href = `/agents/${agent.id}/commissions`; - }, + onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; }, variant: 'secondary' as const, icon: DollarSign, }, @@ -119,6 +136,97 @@ export default function AgentsPage() { loading={isLoading} emptyMessage="No agents found" /> + + {/* Agent Details Modal */} + setSelected(null)} title="Agent Details" size="xl"> + {selected && (() => { + const a = selected; + const initials = (a.user?.fullName || a.agentCode || '?').split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase(); + return ( +
+
+
+
+ {initials} +
+
+

{a.user?.fullName || 'N/A'}

+

{a.agentCode}

+
+
+ + {a.active ? 'Active' : 'Inactive'} + +
+
+
+ {[ + { label: 'Agent Code', value: a.agentCode || '—' }, + { label: 'Commission Rate', value: `${a.commissionRate ?? 0}%` }, + { label: 'Total Bookings', value: (a.totalBookings ?? 0).toLocaleString() }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+ + + +
+

Status

+ + {a.active ? 'Active' : 'Inactive'} + +
+
+
+ +
+ +
+ + + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 06d8a6811..0153887ae 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -252,111 +252,132 @@ export default function AuditLogsPage() { {/* Details Modal */} { - setShowDetailsModal(false); - setSelectedLog(null); - }} - title={`${selectedLog?.action} - ${selectedLog?.entityType}`} - size="lg" + onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }} + title="Audit Log Details" + size="xl" > -
- {/* Basic Info */} -
-
- -

{formatDateTime(selectedLog?.createdAt)}

-
-
- -

- - {selectedLog?.action} - -

-
-
- -

{selectedLog?.entityType}

-
-
- -

- {selectedLog?.entityId || 'System'} -

-
-
+ {selectedLog && (() => { + const l = selectedLog; + const actionColor: Record = { + CREATE: 'from-emerald-600 to-emerald-700', + UPDATE: 'from-blue-600 to-blue-700', + DELETE: 'from-red-600 to-red-700', + LOGIN: 'from-violet-600 to-violet-700', + LOGOUT: 'from-gray-600 to-gray-700', + }; + const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700'; - {/* User Info */} - {selectedLog?.user && ( -
-

User Information

-
-
- -

{selectedLog?.user?.fullName}

+ const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+ ); + + const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+ ); + + return ( +
+
+
+
+

Action

+

{l.action}

+
+
+ {l.entityType} +

{formatDateTime(l.createdAt)}

+
-
- -

{selectedLog?.user?.email}

+
+
+

User

+

{l.user?.fullName || 'System'}

+
+
+

IP Address

+

{l.ipAddress || 'N/A'}

+
-
- )} - {/* Network Info */} - {(selectedLog?.ipAddress || selectedLog?.userAgent) && ( -
-

Network Information

-
- {selectedLog?.ipAddress && ( -
- -

{selectedLog?.ipAddress}

+
+
+ +
+ + + +
+
+ + {l.user && ( +
+ +
+ + + +
+
)} - {selectedLog?.userAgent && ( -
- -

- {selectedLog?.userAgent} -

+ + {(l.ipAddress || l.userAgent) && ( +
+ +
+ +
+

User Agent

+

{l.userAgent || '—'}

+
+
+
+ )} + + {(l.oldData || l.newData) && ( +
+ +
+ {l.oldData && ( +
+

← Before

+
+                            {formatJsonData(l.oldData)}
+                          
+
+ )} + {l.newData && ( +
+

→ After

+
+                            {formatJsonData(l.newData)}
+                          
+
+ )} +
+
+ )} + +
+ +
+
- )} +
+
+ +
+ { setShowDetailsModal(false); setSelectedLog(null); }}>Close
- )} - - {/* Changes */} - {(selectedLog?.oldData || selectedLog?.newData) && ( -
-

Data Changes

-
- {selectedLog?.oldData && ( -
- -
-                      {formatJsonData(selectedLog?.oldData)}
-                    
-
- )} - {selectedLog?.newData && ( -
- -
-                      {formatJsonData(selectedLog?.newData)}
-                    
-
- )} -
-
- )} - - {/* Raw Log ID */} -
- -

{selectedLog?.id}

-
-
+ ); + })()}
); diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 7b43fb677..81581db44 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react'; +import { Download, Eye, XCircle, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; @@ -13,29 +13,32 @@ import { bookingsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { BookingFilters } from '@/types'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + export default function BookingsPage() { - const [filters, setFilters] = useState({ - page: 1, - pageSize: 20, - search: '', - status: '', - }); + const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [selectedBooking, setSelectedBooking] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); + const [deleteError, setDeleteError] = useState(null); const [successMessage, setSuccessMessage] = useState(''); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateTo, setExportDateTo] = useState(''); const [exportColumns, setExportColumns] = useState>({ - bookingRef: true, - passenger: true, - status: true, - bookingType: false, - passengerCount: false, - totalMinor: true, - paymentStatus: true, - createdAt: true, + bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true, + contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true, }); const queryClient = useQueryClient(); @@ -45,10 +48,6 @@ export default function BookingsPage() { queryFn: () => bookingsApi.getAll(filters), }); - if (error) { - console.error('Bookings API Error:', error); - } - const cancelMutation = useMutation({ mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), onSuccess: () => { @@ -56,9 +55,7 @@ export default function BookingsPage() { setSuccessMessage('Booking cancelled successfully'); setTimeout(() => setSuccessMessage(''), 3000); }, - onError: (error: any) => { - alert(`Error: ${error.message || 'Failed to cancel booking'}`); - }, + onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`), }); const deleteMutation = useMutation({ @@ -67,36 +64,32 @@ export default function BookingsPage() { queryClient.invalidateQueries({ queryKey: ['bookings'] }); setDeleteConfirmOpen(false); setBookingToDelete(null); + setDeleteError(null); setSuccessMessage('Booking deleted successfully'); setTimeout(() => setSuccessMessage(''), 3000); }, onError: (error: any) => { - setDeleteConfirmOpen(false); - alert(`Error: ${error.message || 'Failed to delete booking'}`); + setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete booking'); }, }); const handleCancel = async (booking: any) => { - if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) { + if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) { await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' }); } }; - const handleDeleteClick = (booking: any) => { - setBookingToDelete(booking); - setDeleteConfirmOpen(true); - }; - - const handleConfirmDelete = async () => { - if (bookingToDelete) { - await deleteMutation.mutateAsync(bookingToDelete.id); - } - }; + const BOOKING_COLS = [ + { key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' }, + { key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' }, + { key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' }, + { key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' }, + { key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' }, + ]; const confirmExport = () => { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); - if (cols.length === 0) { alert('Please select at least one column'); return; } - + if (!cols.length) { alert('Please select at least one column'); return; } const exportItems = (data?.items || []).filter((b: any) => { if (!exportDateFrom && !exportDateTo) return true; const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; @@ -104,27 +97,27 @@ export default function BookingsPage() { if (exportDateTo && (!d || d > exportDateTo)) return false; return true; }); - const csv = [ - cols.join(','), + BOOKING_COLS.map(c => `"${c.label}"`).join(','), ...exportItems.map((booking: any) => { - const values = cols.map(col => { - switch (col) { + const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { + switch (key) { case 'bookingRef': return booking.bookingRef; - case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; - case 'status': return booking.status; - case 'bookingType': return booking.bookingType || 'N/A'; - case 'passengerCount': return booking.adultCount + booking.childCount; - case 'totalMinor': return booking.totalMinor; + case 'journeyType': return booking.bookingType || 'N/A'; + case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A'; + case 'contactPhone': return booking.contactPhone || 'N/A'; + case 'contactEmail': return booking.contactEmail || 'N/A'; + case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0); case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; - case 'createdAt': return booking.createdAt; + case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency); + case 'status': return booking.status; + case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : ''; default: return ''; } }); return values.map(v => `"${v}"`).join(','); }), ].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); @@ -136,91 +129,61 @@ export default function BookingsPage() { const columns = [ { - key: 'bookingRef', - label: 'Reference', - sortable: true, - render: (booking: any) => ( - {booking.bookingRef} - ), - }, - { - key: 'passenger', - label: 'Passenger', + key: 'bookingRef', label: 'Reference', sortable: true, render: (booking: any) => (
-
{booking.passenger?.fullName || booking.contactEmail || 'Guest'}
-
{booking.contactPhone || booking.passenger?.phone}
+
{booking.bookingRef}
+
{booking.bookingType || 'ONE_WAY'}
), }, { - key: 'bookingType', - label: 'Type', - sortable: true, - render: (booking: any) => booking.bookingType || 'ONE_WAY', - }, - { - key: 'passengerCount', - label: 'Passengers', + key: 'passengerNames', label: 'Names', render: (booking: any) => { - const adults = booking.adultCount || 0; - const children = booking.childCount || 0; - if (adults === 0 && children === 0) return '—'; - const parts = [`Adult: ${adults}`]; - if (children > 0) parts.push(`Child: ${children}`); - return parts.join(' / '); + const names: string[] = booking.passengerNames || []; + if (!names.length) return ; + return
{names.map((n, i) => {n})}
; }, }, { - key: 'status', - label: 'Status', + key: 'contact', label: 'Contact', render: (booking: any) => ( - {booking.status} +
+
{booking.contactPhone || booking.passenger?.phone}
+
{booking.contactEmail || booking.passenger?.email}
+
), }, { - key: 'totalMinor', - label: 'Amount', - sortable: true, - render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency), + key: 'passengerCount', label: 'Passengers', + render: (booking: any) => { + const adults = booking.adultCount || 0, children = booking.childCount || 0; + if (!adults && !children) return '—'; + return <>
Adult: {adults}
Child: {children}
; + }, }, { - key: 'paymentStatus', - label: 'Payment', + key: 'paymentStatus', label: 'Payment', render: (booking: any) => ( - - {booking.paymentIntent?.status || 'PENDING'} - +
+ {booking.paymentIntent?.status || 'PENDING'} +
{formatCurrency(booking.totalMinor, booking.currency)}
+
), }, { - key: 'createdAt', - label: 'Created', - sortable: true, - render: (booking: any) => formatDateTime(booking.createdAt), + key: 'status', label: 'Status', + render: (booking: any) => {booking.status}, }, ]; const actions = [ + { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, { - label: 'View Details', - onClick: (booking: any) => setSelectedBooking(booking), - variant: 'secondary' as const, - icon: Eye, - }, - { - label: 'Cancel Booking', - onClick: handleCancel, - variant: 'danger' as const, - icon: XCircle, - show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED', - }, - { - label: 'Delete', - onClick: handleDeleteClick, - variant: 'danger' as const, - icon: Trash2, + label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle, + show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED', }, + { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -235,9 +198,7 @@ export default function BookingsPage() {
{successMessage && ( -
- ✓ {successMessage} -
+
✓ {successMessage}
)} {error && (
@@ -246,222 +207,196 @@ export default function BookingsPage() { )}
- setFilters({ ...filters, search: e.target.value, page: 1 })} - /> + setFilters({ ...filters, search: e.target.value, page: 1 })} />
- setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}> - + - More Filters
- - - + {data?.meta && ( - setFilters({ ...filters, page })} - /> + setFilters({ ...filters, page })} /> )}
{/* Booking Details Modal */} setSelectedBooking(null)} title="Booking Details" size="xl"> - {selectedBooking && ( -
-
-
- -

{selectedBooking.bookingRef}

-
-
- -
- {selectedBooking.status} -
-
-
- -

{selectedBooking.bookingType || 'N/A'}

-
-
- -

{formatDateTime(selectedBooking.createdAt)}

-
-
- -
- + {selectedBooking && (() => { + const b = selectedBooking; + const isRoundTrip = b.bookingType === 'ROUND_TRIP' || b.bookingType === 'ROUND_TRIP_TRANSIT'; + return (
-

Passenger Information

-
-
- -

{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}

-
-
- -

{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}

-
-
- -

{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}

-
-
- -

{selectedBooking.passengerId || 'N/A'}

-
-
-
- -
- -
-

Journey Details

-
-
- -

{selectedBooking.adultCount || 0}

-
-
- -

{selectedBooking.childCount || 0}

-
-
- -

{selectedBooking.scheduleId || 'N/A'}

-
-
- -

{selectedBooking.promoCode || 'None'}

-
-
-
- -
- -
-

Payment Information

-
-
- -

{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}

-
-
- -
- - {selectedBooking.paymentIntent?.status || 'PENDING'} - + {/* Gradient header */} +
+
+
+

Booking Reference

+

{b.bookingRef}

+
+
+ {b.status} +

{formatDateTime(b.createdAt)}

-
- -

{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}

-
-
- -

{selectedBooking.displayCurrency || selectedBooking.currency}

+
+ {[ + (b.bookingType || 'ONE_WAY').replace(/_/g, ' '), + `${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`, + b.displayCurrency || b.currency || 'ETB', + ].map((tag) => ( + + {tag} + + ))}
-
-
+
+ {/* Passenger */} +
+ +
+ + + + +
+
-
-

Additional Information

-
-
- -

{selectedBooking.source || 'N/A'}

-
-
- -

{formatDateTime(selectedBooking.updatedAt)}

-
+ {/* Journey */} +
+ +
+ + + + + + + + +
+
+ + {/* Return leg */} + {isRoundTrip && ( +
+ +
+ + + + +
+
+ )} + + {/* Payment */} +
+ +
+
+

Total Amount

+

{formatCurrency(b.totalMinor, b.currency || 'ETB')}

+ {b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && ( +

+ ≈ {formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)} +

+ )} +
+
+

Payment Status

+ {b.paymentIntent?.status || 'PENDING'} +
+ + + + +
+
+ + {/* Seats */} + {b.seats && b.seats.length > 0 && ( +
+ +
+ {b.seats.map((bs: any, i: number) => ( +
+
+ {i + 1} +
+

{bs.passengerName || '—'}

+

+ {bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''} + {bs.verifaydaVerified ? ' · ✓ Verified' : ''} +

+
+
+
+

{bs.seat?.seatNumber || bs.seatId || '—'}

+

{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}

+
+
+ ))} +
+
+ )} + + {/* Timestamps */} +
+ +
+ + + +
+
+
+ +
+ setSelectedBooking(null)}>Close
- -
- setSelectedBooking(null)}>Close -
-
- )} + ); + })()} - {/* Delete Confirmation Dialog */} { setDeleteConfirmOpen(false); setBookingToDelete(null); }} - onConfirm={handleConfirmDelete} + onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); }} + onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }} title="Delete Booking" - message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`} - confirmText="Delete" - cancelText="Cancel" - isLoading={deleteMutation.isPending} - isDanger={true} + message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`} + confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger + error={deleteError ?? undefined} /> - {/* Export Modal */} setExportModalOpen(false)} title="Export Bookings" size="md">
-
- - setExportDateFrom(e.target.value)} /> -
-
- - setExportDateTo(e.target.value)} /> -
+
setExportDateFrom(e.target.value)} />
+
setExportDateTo(e.target.value)} />
-

Select Columns

- {[ - { key: 'bookingRef', label: 'Booking Reference' }, - { key: 'passenger', label: 'Passenger' }, - { key: 'status', label: 'Status' }, - { key: 'bookingType', label: 'Booking Type' }, - { key: 'passengerCount', label: 'Passenger Count' }, - { key: 'totalMinor', label: 'Amount' }, - { key: 'paymentStatus', label: 'Payment Status' }, - { key: 'createdAt', label: 'Created At' }, - ].map((col) => ( + {BOOKING_COLS.map((col) => ( ))}
-
setExportModalOpen(false)}>Cancel Export CSV diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/layout.tsx new file mode 100644 index 000000000..86e532cc2 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/layout.tsx @@ -0,0 +1,7 @@ +'use client'; + +import DashboardLayout from '../dashboard/layout'; + +export default function ExcessBaggageLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx new file mode 100644 index 000000000..75ba7d6c4 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { RefreshCw, Send } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import { excessBaggageApi } from '@/lib/api'; +import { formatDateTime, formatCurrency } from '@/lib/utils'; + +const STATUS_VARIANT: Record = { + PENDING: 'PENDING', + PAID: 'CONFIRMED', + CASH_COLLECTED: 'CONFIRMED', + EXPIRED: 'CANCELLED', + WAIVED: 'CANCELLED', +}; + +export default function ExcessBaggagePage() { + const queryClient = useQueryClient(); + const [filters, setFilters] = useState({ status: '', bookingRef: '', page: '1' }); + const [waiveModal, setWaiveModal] = useState(null); + const [waiveReason, setWaiveReason] = useState(''); + const [waiveError, setWaiveError] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['excess-baggage', filters], + queryFn: () => excessBaggageApi.getAll({ status: filters.status || undefined, bookingRef: filters.bookingRef || undefined, page: filters.page }), + }); + + const waiveMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setWaiveModal(null); + setWaiveReason(''); + setWaiveError(null); + }, + onError: (e: any) => setWaiveError(e?.response?.data?.message || e?.message || 'Failed to waive'), + }); + + const resendMutation = useMutation({ + mutationFn: (id: string) => excessBaggageApi.resendLink(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), + }); + + const columns = [ + { + key: 'booking', label: 'Booking', + render: (c: any) => ( +
+
{c.booking?.bookingRef ?? '—'}
+
{formatDateTime(c.createdAt)}
+
+ ), + }, + { + key: 'weight', label: 'Excess / Charge', + render: (c: any) => ( +
+
{c.excessWeightKg} kg
+
{formatCurrency(c.feePerKgMinor, c.currency)}/kg
+
+ ), + }, + { + key: 'total', label: 'Total', + render: (c: any) => {formatCurrency(c.totalMinor, c.currency)}, + }, + { + key: 'status', label: 'Status', + render: (c: any) => ( + + {c.status.replace('_', ' ')} + + ), + }, + { + key: 'contact', label: 'Contact', + render: (c: any) => ( +
+ {c.contactPhone &&
{c.contactPhone}
} + {c.contactEmail &&
{c.contactEmail}
} + {!c.contactPhone && !c.contactEmail && '—'} +
+ ), + }, + { + key: 'expires', label: 'Expires', + render: (c: any) => ( + + {formatDateTime(c.expiresAt)} + + ), + }, + ]; + + const actions = [ + { + label: 'Resend Link', + icon: Send, + variant: 'secondary' as const, + onClick: (c: any) => resendMutation.mutate(c.id), + hidden: (c: any) => c.status !== 'PENDING', + }, + { + label: 'Waive', + icon: RefreshCw, + variant: 'secondary' as const, + onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); }, + hidden: (c: any) => ['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status), + }, + ]; + + return ( +
+
+
+

Excess Baggage

+

Track and manage excess baggage charges at boarding

+
+
+ +
+
+
+ + setFilters({ ...filters, bookingRef: e.target.value, page: '1' })} + /> +
+
+ + +
+
+
+ + + + {/* Waive Modal */} + setWaiveModal(null)} + title="Waive Charge" + size="sm" + > + {waiveModal && ( +
+

+ Waiving charge of{' '} + + {formatCurrency(waiveModal.totalMinor, waiveModal.currency)} + {' '} + for booking {waiveModal.booking?.bookingRef}. +

+
+ + setWaiveReason(e.target.value)} + /> +
+ {waiveError &&

{waiveError}

} +
+ setWaiveModal(null)}>Cancel + waiveMutation.mutate({ id: waiveModal.id, reason: waiveReason })} + > + Confirm Waive + +
+
+ )} +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx index 242f9bfd9..cc8ac9e7b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx @@ -2,15 +2,37 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { AlertTriangle, CheckCircle, Ban } from 'lucide-react'; +import { AlertTriangle, CheckCircle, Ban, Eye } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; import { fraudApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + +const SEVERITY_GRAD: Record = { + CRITICAL: 'from-red-700 to-red-800', + HIGH: 'from-red-600 to-red-700', + MEDIUM: 'from-amber-500 to-amber-600', + LOW: 'from-blue-500 to-blue-600', +}; + export default function FraudDetectionPage() { const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); + const [selected, setSelected] = useState(null); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -40,10 +62,7 @@ export default function FraudDetectionPage() { const handleBlockUser = async (alert: any) => { if (confirm(`Block user ${alert.user?.email}?`)) { - await blockUserMutation.mutateAsync({ - userId: alert.userId, - reason: `Fraud alert: ${alert.ruleType}`, - }); + await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` }); } }; @@ -52,7 +71,7 @@ export default function FraudDetectionPage() { key: 'severity', label: 'Severity', render: (alert: any) => ( - + {alert.severity} ), @@ -62,7 +81,7 @@ export default function FraudDetectionPage() { label: 'Rule Type', render: (alert: any) => (
- + {alert.ruleType}
), @@ -80,9 +99,7 @@ export default function FraudDetectionPage() { { key: 'description', label: 'Description', - render: (alert: any) => ( - {alert.description || alert.details} - ), + render: (alert: any) => {alert.description || alert.details}, }, { key: 'status', @@ -102,6 +119,12 @@ export default function FraudDetectionPage() { ]; const actions = [ + { + label: 'View Details', + onClick: (alert: any) => setSelected(alert), + variant: 'secondary' as const, + icon: Eye, + }, { label: 'Acknowledge', onClick: handleAcknowledge, @@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
- setFilters({ ...filters, search: e.target.value })} - /> + setFilters({ ...filters, search: e.target.value })} />
- setFilters({ ...filters, severity: e.target.value })}> @@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
- setFilters({ ...filters, status: e.target.value })}> @@ -174,6 +183,121 @@ export default function FraudDetectionPage() { loading={isLoading} emptyMessage="No fraud alerts found" /> + + {/* Fraud Alert Details Modal */} + setSelected(null)} title="Fraud Alert Details" size="xl"> + {selected && (() => { + const al = selected; + const grad = SEVERITY_GRAD[al.severity] || 'from-gray-600 to-gray-700'; + return ( +
+
+
+
+

Fraud Alert

+

{al.ruleType}

+
+
+ + {al.severity} + +
+ + {al.acknowledged ? 'Acknowledged' : 'Pending'} + +
+

{formatDateTime(al.createdAt)}

+
+
+
+ {[ + { label: 'Severity', value: al.severity || '—' }, + { label: 'Rule Type', value: al.ruleType || '—' }, + { label: 'User', value: al.user?.fullName || al.user?.email || '—' }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+ + +
+

Status

+ + {al.acknowledged ? 'Acknowledged' : 'Pending'} + +
+ +
+

Description

+

{al.description || al.details || '—'}

+
+
+
+ +
+ +
+ + + + +
+

Blocked

+ + {al.user?.isBlocked ? 'Blocked' : 'Not Blocked'} + +
+
+
+ + {al.bookingId && ( +
+ +
+ + + +
+
+ )} + + {al.acknowledged && ( +
+ +
+ + + +
+
+ )} + +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/health/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/health/layout.tsx new file mode 100644 index 000000000..cd6d8b909 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/health/layout.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function HealthLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { isAuthenticated } = useAuthStore(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => setIsLoading(false), 100); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!isLoading && !isAuthenticated) router.push('/login'); + }, [isAuthenticated, router, isLoading]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) return null; + + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/health/page.tsx b/apps/edr-passenger-web/backoffice/src/app/health/page.tsx new file mode 100644 index 000000000..c5b737727 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/health/page.tsx @@ -0,0 +1,361 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { + Activity, + Database, + Info, + RefreshCw, + CheckCircle2, + XCircle, + Clock, + Server, + Cpu, + Globe, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; + +// Dedicated bare client — no auth token, no 401 redirect interceptor. +// Health probes are public. We unwrap the { success, data } envelope explicitly. +const healthClient = axios.create({ baseURL: API_URL }); + +async function fetchHealth(path: string) { + const res = await healthClient.get<{ success: boolean; data: any }>(path); + return res.data?.data ?? res.data; +} + +function StatusDot({ ok }: { ok: boolean | null }) { + if (ok === null) + return ; + return ok ? ( + + ) : ( + + ); +} + +function StatusBadge({ ok }: { ok: boolean | null }) { + if (ok === null) + return Checking…; + return ok ? ( + Healthy + ) : ( + Degraded + ); +} + +function MetricRow({ label, value, icon: Icon }: { label: string; value: string; icon: any }) { + return ( +
+
+ + {label} +
+ {value} +
+ ); +} + +export default function HealthPage() { + const { data: liveness, isFetching: l1, dataUpdatedAt: t1, refetch: r1, error: e1 } = useQuery({ + queryKey: ['health-liveness'], + queryFn: () => fetchHealth('/health'), + refetchInterval: 30_000, + retry: 1, + }); + + const { data: readiness, isFetching: l2, dataUpdatedAt: t2, refetch: r2, error: e2 } = useQuery({ + queryKey: ['health-readiness'], + queryFn: () => fetchHealth('/health/ready'), + refetchInterval: 30_000, + retry: 1, + }); + + const { data: info, isFetching: l3, dataUpdatedAt: t3, refetch: r3 } = useQuery({ + queryKey: ['health-info'], + queryFn: () => fetchHealth('/health/info'), + refetchInterval: 60_000, + retry: 1, + }); + + const livenessOk = e1 ? false : liveness ? liveness.status === 'ok' : null; + const readinessOk = e2 ? false : readiness ? readiness.status === 'ok' : null; + const dbOk = readiness?.checks?.database?.status === 'ok'; + const overallOk = + livenessOk === null || readinessOk === null ? null : livenessOk && readinessOk; + + const fmt = (ms: number) => new Date(ms).toLocaleTimeString(); + const fmtUptime = (s: number) => { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return `${h}h ${m}m ${sec}s`; + }; + + function refetchAll() { r1(); r2(); r3(); } + + return ( +
+ {/* Header */} +
+
+

System Health

+

+ Live status of the EDR Passenger API — auto-refreshes every 30 s +

+
+ +
+ + {/* Overall banner */} +
+ {overallOk === null ? ( + + ) : overallOk ? ( + + ) : ( + + )} +
+

+ {overallOk === null + ? 'Checking system status…' + : overallOk + ? 'All systems operational' + : 'Service degraded'} +

+

+ EDR Passenger API · {API_URL} +

+
+
+ + {/* Probe cards */} +
+ {/* Liveness */} +
+
+
+
+ +
+
+

Liveness

+

GET /health

+
+
+ +
+
+ + {t1 > 0 && ( + + {fmt(t1)} + + )} +
+ {e1 && ( +

+ {(e1 as any)?.message ?? 'Request failed'} +

+ )} +

+ Confirms the process is alive and accepting connections. Checked every 30 s. +

+
+ + {/* Readiness */} +
+
+
+
+ +
+
+

Readiness

+

GET /health/ready

+
+
+ +
+
+ + {t2 > 0 && ( + + {fmt(t2)} + + )} +
+ {e2 && ( +

+ {(e2 as any)?.message ?? 'Request failed'} +

+ )} +

+ Runs a live database ping. Latency:{' '} + + {readiness?.checks?.database?.latencyMs != null + ? `${readiness.checks.database.latencyMs} ms` + : '—'} + +

+
+ + {/* App Info */} +
+
+
+
+ +
+
+

App Info

+

GET /health/info

+
+
+ +
+
+ + {t3 > 0 && ( + + {fmt(t3)} + + )} +
+

+ Version, environment, and uptime. Refreshed every 60 s. +

+
+
+ + {/* Detailed panels */} +
+ {/* Database detail */} +
+
+ +

Database

+
+ +
+
+ + + {readiness?.checks?.database?.error && ( +
+

+ {readiness.checks.database.error} +

+
+ )} +
+ + {/* App info detail */} +
+
+ +

Application

+
+ + + + + 0 ? new Date(t3).toLocaleString() : '—'} + icon={Clock} + /> +
+
+ + {/* Rate limits reference */} +
+
+ +

Rate Limits

+
+
+ + + + + + + + + + {[ + { tier: 'auth', limit: '5 req / min', scope: '/auth, /fayda/verification' }, + { tier: 'strict', limit: '20 req / min', scope: '/bookings, /passengers, /payments, /wallet' }, + { tier: 'default', limit: '100 req / min', scope: 'All other endpoints' }, + { tier: 'exempt', limit: '—', scope: '/health/*, /internal/payments/*, payment webhooks' }, + ].map((row) => ( + + + + + + ))} + +
TierLimitApplied to
+ + {row.tier} + + {row.limit}{row.scope}
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/live/page.tsx b/apps/edr-passenger-web/backoffice/src/app/live/page.tsx index 9091e1d78..034731686 100644 --- a/apps/edr-passenger-web/backoffice/src/app/live/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/live/page.tsx @@ -1,44 +1,186 @@ 'use client'; -import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, MapPin } from 'lucide-react'; +import { Train, MapPin, Users, Clock } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; -import ActionButton from '@/components/ui/ActionButton'; +import Badge from '@/components/ui/Badge'; +import { liveApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; -export default function Page() { - const [filters, setFilters] = useState({ search: '' }); +export default function LiveTrackingPage() { + const { data: trips, isLoading } = useQuery({ + queryKey: ['live-trips'], + queryFn: liveApi.getTrips, + refetchInterval: 30000, + }); + + const { data: crowdSignals } = useQuery({ + queryKey: ['crowd-signals'], + queryFn: liveApi.getCrowdSignals, + refetchInterval: 60000, + }); + + const tripsArray = Array.isArray(trips) ? trips : (trips as any)?.items || []; + const signalsArray = Array.isArray(crowdSignals) ? crowdSignals : (crowdSignals as any)?.items || []; + + const columns = [ + { + key: 'train', + label: 'Train', + render: (trip: any) => ( +
+ +
+
{trip.schedule?.train?.name || trip.trainName || 'N/A'}
+
{trip.schedule?.train?.number || trip.trainNumber || ''}
+
+
+ ), + }, + { + key: 'route', + label: 'Route', + render: (trip: any) => ( +
+
{trip.schedule?.originStation?.name || trip.origin || 'N/A'}
+
→ {trip.schedule?.destinationStation?.name || trip.destination || 'N/A'}
+
+ ), + }, + { + key: 'location', + label: 'Location', + render: (trip: any) => ( +
+ + {trip.currentStation?.name || trip.lastKnownStation || 'En route'} +
+ ), + }, + { + key: 'departure', + label: 'Departure', + render: (trip: any) => ( + + {trip.schedule?.departureAt ? formatDateTime(trip.schedule.departureAt) : 'N/A'} + + ), + }, + { + key: 'passengers', + label: 'Passengers', + render: (trip: any) => ( +
+ + {trip.passengerCount ?? trip.bookedSeats ?? '—'} +
+ ), + }, + { + key: 'status', + label: 'Status', + render: (trip: any) => ( + + {(trip.status || 'SCHEDULED').replace(/_/g, ' ')} + + ), + }, + { + key: 'delay', + label: 'Delay', + render: (trip: any) => { + const delay = trip.delayMinutes ?? trip.delay; + if (!delay) return On time; + return ( +
+ + +{delay} min +
+ ); + }, + }, + ]; + + const crowdColumns = [ + { + key: 'station', + label: 'Station', + render: (s: any) => {s.station?.name || s.stationName || 'N/A'}, + }, + { + key: 'level', + label: 'Crowd Level', + render: (s: any) => ( + + {s.level || s.crowdLevel || 'LOW'} + + ), + }, + { + key: 'count', + label: 'Estimated Count', + render: (s: any) => {s.estimatedCount ?? s.count ?? '—'}, + }, + { + key: 'updatedAt', + label: 'Last Updated', + render: (s: any) => {s.updatedAt ? formatDateTime(s.updatedAt) : 'N/A'}, + }, + ]; + + const enRoute = tripsArray.filter((t: any) => t.status === 'EN_ROUTE' || t.status === 'BOARDING').length; + const delayed = tripsArray.filter((t: any) => t.delayMinutes > 0 || t.delay > 0).length; return (
-
-
-

Live Tracking

-

Real-time train tracking and status

-
- Add New +
+

Live Tracking

+

Real-time train tracking and station crowd signals

-
-
-
- - setFilters({ ...filters, search: e.target.value })} - /> -
+
+
+

Active Trips

+

{tripsArray.length}

+
+
+

En Route / Boarding

+

{enRoute}

+
+
+

Delayed

+

{delayed}

-

- Live Tracking module - Connect to API endpoint -

+

Active Trips

+
+ + {signalsArray.length > 0 && ( +
+

Station Crowd Signals

+ +
+ )}
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index fe0917aa3..0e821873e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -4,141 +4,295 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useTheme } from '@/lib/theme-store'; -import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react'; +import { + Eye, EyeOff, Sun, Moon, ArrowRight, Loader2, + TicketCheck, Users, TrendingUp, ShieldCheck, +} from 'lucide-react'; + +const EDR_GREEN = 'rgb(20, 113, 76)'; + +const features = [ + { icon: TicketCheck, label: 'Booking Management', desc: 'Full lifecycle booking operations' }, + { icon: Users, label: 'Passenger Services', desc: 'Profiles, loyalty & wallet' }, + { icon: TrendingUp, label: 'Revenue Analytics', desc: 'Real-time reports & insights' }, + { icon: ShieldCheck, label: 'Fraud Detection', desc: 'Automated risk monitoring' }, +]; export default function LoginPage() { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); const [showPassword, setShowPassword] = useState(false); const [isMounted, setIsMounted] = useState(false); - const router = useRouter(); - const { login } = useAuthStore(); + const [emailFocused, setEmailFocused] = useState(false); + const [passwordFocused, setPasswordFocused] = useState(false); + + const router = useRouter(); + const { login } = useAuthStore(); const { isDark, toggleTheme } = useTheme(); - useEffect(() => { - setIsMounted(true); - }, []); + useEffect(() => { setIsMounted(true); }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(''); - try { await login(email, password); router.push('/dashboard'); } catch (err: any) { - const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.'; - setError(message); + setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.'); } finally { setLoading(false); } }; - if (!isMounted) { - return null; - } + if (!isMounted) return null; return ( -
- {/* Full Screen Banner Background */} -
+
- {/* Content Overlay */} -
-
- {/* Login Card with Shadow */} -
- {/* Card Header with Logo, App Name and Theme Toggle */} -
-
-
- -
-
-

Ethio-Djibouti Railway

-

Passenger Back-office

-
-
- - + {/* ── LEFT PANEL — form ── */} +
+ + {/* Top bar */} +
+ {/* Logo — always visible on the form panel */} +
+
+ + + + + + +
+
+
ETHIO-DJIBOUTI
+
Railway
+
+
+ + +
+ + {/* Form area */} +
+
+ + {/* Heading */} +
+

+ Sign in to continue +

+

+ Enter your credentials to access the back-office. +

- {/* Card Body */} -
-
-

Welcome back!

-

Sign in to continue.

-
- - {error && ( -
- {error} + {/* Error */} + {error && (
+
+
+ !
- )} +

{error}

+
+ )} -
-
- + + + {/* Email field */} +
+ +
setEmail(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" - placeholder="name@email.com" + onChange={(e) => { setEmail(e.target.value); setError(''); }} + onFocus={() => setEmailFocused(true)} + onBlur={() => setEmailFocused(false)} + className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" + placeholder="name@edr.com" required + autoComplete="email" />
+
-
- -
- setPassword(e.target.value)} - className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" - placeholder="••••••••" - required - /> - -
+ {/* Password field */} +
+
+ +
+
+ { setPassword(e.target.value); setError(''); }} + onFocus={() => setPasswordFocused(true)} + onBlur={() => setPasswordFocused(false)} + className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" + placeholder="••••••••••" + required + autoComplete="current-password" + /> + +
+
- - + {/* Submit */} + + + + {/* Divider */} +
+
+ +

+ Access is restricted to authorised EDR staff only. All sessions are logged and audited. +

+
+ + {/* Bottom bar */} +
+ + Back-office · v1.0 + + + Need help? support@edr.com + +
+
+ + {/* ── RIGHT PANEL — photo ── */} +
+ {/* Layer 1 — base photo, desaturated */} +
+ + {/* Layer 2 — brand green color wash */} +
+ + {/* Content */} +
+ {/* Badge */} +
+
+
+ Back-office Portal v1.0 +
+
+ + {/* Hero text */} +
+

+ Passenger
+ + Management + +
System +

+

+ Unified platform for booking operations, passenger services, revenue analytics, and real-time train management. +

+
+ + {/* Feature grid */} +
+ {features.map(({ icon: Icon, label, desc }) => ( +
+
+ +
+
+
{label}
+
{desc}
+
+
+ ))} +
+ + {/* Bottom rule */} +
+ © 2026 Ethio-Djibouti Railway S.C. — Secure · Encrypted · Monitored +
+
); diff --git a/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx b/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx index 75d52f940..7fbfffbf0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx @@ -2,15 +2,44 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, Star } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; import { loyaltyApi } from '@/lib/api'; -import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { formatDateTime } from '@/lib/utils'; + +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + +const TIER_COLORS: Record = { + BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800', + SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600', + GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800', + PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800', +}; + +const TIER_GRAD: Record = { + BRONZE: 'from-orange-500 to-orange-600', + SILVER: 'from-gray-500 to-gray-600', + GOLD: 'from-yellow-500 to-yellow-600', + PLATINUM: 'from-indigo-600 to-indigo-700', +}; export default function LoyaltyPage() { const [filters, setFilters] = useState({ search: '', tier: '' }); + const [selected, setSelected] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['loyalty', filters], @@ -18,11 +47,24 @@ export default function LoyaltyPage() { }); const columns = [ - { key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' }, - { key: 'tier', label: 'Tier', render: (account: any) => {account.tier} }, - { key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 }, - { key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 }, - ]; + { key: 'passenger', label: 'Passenger', render: (account: any) => ( +
+
{account.passenger?.fullName || account.user?.fullName || 'N/A'}
+
{account.passenger?.email || account.user?.email || ''}
+
+ )}, + { key: 'tier', label: 'Tier', render: (account: any) => ( + + {account.tier} + + )}, + { key: 'pointsBalance', label: 'Points', render: (account: any) => (account.pointsBalance ?? 0).toLocaleString() }, + { key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => (account.lifetimePoints ?? 0).toLocaleString() }, + ]; + + const actions = [ + { label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye }, + ]; return (
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ + {/* Loyalty Details Modal */} + setSelected(null)} title="Loyalty Account Details" size="xl"> + {selected && (() => { + const a = selected; + const tier = a.tier || 'BRONZE'; + const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE; + const grad = TIER_GRAD[tier] || 'from-gray-600 to-gray-700'; + const passengerName = a.passenger?.fullName || a.user?.fullName || 'N/A'; + return ( +
+
+
+
+ +
+
+

{passengerName}

+

{a.passenger?.email || a.user?.email || ''}

+
+
+ + {tier} + +
+
+
+ {[ + { label: 'Points Balance', value: (a.pointsBalance ?? 0).toLocaleString() }, + { label: 'Lifetime Points', value: (a.lifetimePoints ?? 0).toLocaleString() }, + { label: 'Points Redeemed', value: (a.pointsRedeemed ?? 0).toLocaleString() }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+ + + + + + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx index bb771d7df..049a12237 100644 --- a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx @@ -1,21 +1,102 @@ 'use client'; import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Send } from 'lucide-react'; -import Table from '@/components/ui/Table'; +import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; - -const templates = [ - { id: '1', name: 'Booking Confirmation', channel: 'EMAIL', subject: 'Your booking is confirmed', active: true }, - { id: '2', name: 'Payment Receipt', channel: 'EMAIL', subject: 'Payment received', active: true }, - { id: '3', name: 'Trip Reminder', channel: 'SMS', body: 'Your trip is tomorrow', active: true }, - { id: '4', name: 'Cancellation Notice', channel: 'PUSH', body: 'Your booking has been cancelled', active: false }, -]; +import ActionButton from '@/components/ui/ActionButton'; +import { notificationsApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; export default function NotificationsPage() { const [showModal, setShowModal] = useState(false); - const [activeTab, setActiveTab] = useState<'templates' | 'send'>('templates'); + const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'history'>('templates'); + const [sendForm, setSendForm] = useState({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' }); + const [sendError, setSendError] = useState(null); + const [sendSuccess, setSendSuccess] = useState(false); + const queryClient = useQueryClient(); + + const { data: templates, isLoading: templatesLoading } = useQuery({ + queryKey: ['notification-templates'], + queryFn: notificationsApi.getTemplates, + enabled: activeTab === 'templates', + }); + + const { data: historyData, isLoading: historyLoading } = useQuery({ + queryKey: ['notification-history'], + queryFn: () => notificationsApi.getHistory({ take: 50 }), + enabled: activeTab === 'history', + }); + + const createTemplateMutation = useMutation({ + mutationFn: notificationsApi.createTemplate, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); + setShowModal(false); + }, + }); + + const sendMutation = useMutation({ + mutationFn: notificationsApi.send, + onSuccess: () => { + setSendSuccess(true); + setSendError(null); + setSendForm({ recipientType: 'ALL', channel: 'EMAIL', subject: '', message: '' }); + setTimeout(() => setSendSuccess(false), 4000); + }, + onError: (e: any) => setSendError(e?.response?.data?.message || e?.message || 'Failed to send'), + }); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + setSendError(null); + await sendMutation.mutateAsync(sendForm); + }; + + const templatesArray = Array.isArray(templates) ? templates : (templates as any)?.items || []; + const historyArray = Array.isArray(historyData) ? historyData : (historyData as any)?.items || []; + + const templateColumns = [ + { key: 'name', label: 'Template Name', render: (t: any) => {t.name} }, + { key: 'channel', label: 'Channel', render: (t: any) => {t.channel || t.type} }, + { + key: 'subject', + label: 'Subject / Body', + render: (t: any) => {t.subject || t.body || t.content || '—'}, + }, + { + key: 'active', + label: 'Status', + render: (t: any) => ( + + {t.isActive !== false ? 'Active' : 'Inactive'} + + ), + }, + { key: 'createdAt', label: 'Created', render: (t: any) => {formatDateTime(t.createdAt)} }, + ]; + + const historyColumns = [ + { key: 'channel', label: 'Channel', render: (n: any) => {n.channel || n.type || 'EMAIL'} }, + { key: 'title', label: 'Title', render: (n: any) => {n.title || n.subject || '—'} }, + { + key: 'recipient', + label: 'Recipient', + render: (n: any) => {n.passenger?.email || n.passenger?.phone || n.recipientEmail || n.recipientPhone || '—'}, + }, + { + key: 'status', + label: 'Status', + render: (n: any) => ( + + {n.status || 'SENT'} + + ), + }, + { key: 'createdAt', label: 'Sent At', render: (n: any) => {formatDateTime(n.createdAt)} }, + ]; return (
@@ -24,109 +105,122 @@ export default function NotificationsPage() {

Notifications

Manage notification templates and send messages

- + {activeTab === 'templates' && ( + setShowModal(true)}>New Template + )}
- - + {(['templates', 'send', 'history'] as const).map((tab) => ( + + ))}
- {activeTab === 'templates' ? ( + {activeTab === 'templates' && (
- ( - {item.channel} - )}, - { key: 'subject', label: 'Subject/Body', render: (item) => item.subject || item.body }, - { key: 'active', label: 'Status', render: (item) => ( - - {item.active ? 'Active' : 'Inactive'} - - )}, - ]} + - ) : ( + )} + + {activeTab === 'send' && (
-
-
- - + {sendSuccess && ( +
+ ✓ Notification sent successfully
-
- - + )} + +
+
+ + +
+
+ + +
- + setSendForm({ ...sendForm, subject: e.target.value })} required />
- + + +