Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-05 00:32:03 +03:00
243 changed files with 16021 additions and 4390 deletions

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
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, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
import { RegisterDto, LoginDto, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Passenger Auth')
@@ -14,14 +14,28 @@ export class AuthController {
@Post('register')
@IsPublic()
@ApiOperation({ summary: 'Register new passenger account' })
@ApiResponse({ status: 201, description: 'Account created. Returns token + user.' })
@ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' })
@ApiResponse({
status: 201,
description:
'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.',
})
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Request() req: any, @Body() dto: RegisterDto) {
return this.passengerAuthService.register(dto, req);
}
@Post('register/resend-code')
@IsPublic()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Resend the registration verification code for a pending account' })
@ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' })
@ApiBody({ type: ResendRegistrationCodeDto })
resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) {
return this.passengerAuthService.resendRegistrationCode(dto, req);
}
@Post('login')
@IsPublic()
@HttpCode(HttpStatus.OK)

View File

@@ -1,6 +1,6 @@
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
import { IsEmail, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
export class NameDto {
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
@@ -29,15 +29,16 @@ export class RegisterDto {
@ValidateNested()
@Type(() => NameDto)
name: NameDto;
}
@ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' })
@IsString()
@MinLength(8)
password: string;
export class ResendRegistrationCodeDto {
@ApiProperty({ example: 'kelemu@email.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123', format: 'password' })
@ApiProperty({ example: '+251912345678' })
@IsString()
confirmPassword: string;
phoneNumber: string;
}
export class LoginDto {

View File

@@ -50,14 +50,17 @@ export class PassengerAuthService {
const iamAuthService = await this.resolveIamAuthService(req);
const { token, refreshToken } = await iamAuthService.signupWithPassword({
// IAM `signup` creates the user as PENDING/isActive=false with NO credential and
// SMS-sends a 6-digit verification code. The account cannot log in until the code is
// redeemed via PATCH /v1/auth/set-password. We intentionally discard the session
// token `signup` returns — the account is not verified yet, so it must never reach
// the client.
await iamAuthService.signup({
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<IamUserRow[]>(
@@ -70,20 +73,98 @@ export class PassengerAuthService {
}
const iamUserId = iamRows[0].id;
let passengerId: string;
// The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned
// here — `login()` lazy-provisions it on first successful login, so satellites exist
// only for verified users who complete set-password and sign in.
return {
iamUserId,
email: dto.email,
phoneNumber: dto.phoneNumber,
requiresPasswordSetup: true,
};
}
/**
* Immediate-activation account creation used by the payment-gated guest-checkout
* "create account" path only. Unlike the public `register()` (OTP-gated), this creates a
* ready-to-use account from the password entered at checkout and provisions the passenger
* satellite synchronously so the booking can attach to it. Do NOT wire this to the public
* registration form — that flow must stay behind SMS verification.
*/
async registerWithPassword(
dto: {
email: string;
username: string;
phoneNumber: string;
name: { en: string; am: string };
password: string;
},
req: any,
): Promise<{ iamUserId: string; passengerId: string }> {
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);
await iamAuthService.signupWithPassword({
email: dto.email,
username: dto.username,
phoneNumber: dto.phoneNumber,
userType: EUserType.INDIVIDUAL,
name: dto.name,
password: dto.password,
confirmPassword: dto.password,
});
const iamRows = await this.dataSource.query<IamUserRow[]>(
`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;
try {
const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' });
passengerId = result.passengerId;
return { iamUserId, 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 resendRegistrationCode(
dto: { email: string; phoneNumber: string },
req: any,
): Promise<{ sent: boolean }> {
// Only regenerate for accounts still pending password setup. A fully-registered user
// should use forgot-password instead. Always return { sent: true } to avoid leaking
// whether the email/phone maps to a pending account (enumeration guard).
const users = await this.dataSource.query<{ email: string; phone_number: string }[]>(
`SELECT email, phone_number FROM iam.users
WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`,
[dto.email, dto.phoneNumber],
);
if (!users.length) return { sent: true };
const iamAuthService = await this.resolveIamAuthService(req);
try {
await iamAuthService.generateVerificationCode({
email: users[0].email,
phoneNumber: users[0].phone_number,
type: EOtpType.VERIFY_PHONE_NUMBER,
});
} catch (err) {
this.logger.error(
`[PassengerAuthService] resend registration code failed for ${dto.email}`,
(err as Error).message,
);
}
return { sent: true };
}
async login(dto: LoginDto, req: any) {
@@ -180,6 +261,9 @@ export class PassengerAuthService {
return {
iamUserId,
// Top-level passengerId keeps the profile shape consistent with the login
// response so the web User object always carries it (the JWT does not).
passengerId: passenger.id,
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,

View File

@@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator';
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDate, MaxDate } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -10,10 +10,12 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' })
@IsDateString()
@Transform(({ value }) => value)
// Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so
// @MaxDate (which requires an actual Date instance) evaluates correctly.
@Transform(({ value }) => (value ? new Date(value) : value))
@IsDate()
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
dateOfBirth: string;
dateOfBirth: Date;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@@ -44,10 +46,12 @@ export class RoundTripPassengerDto {
example: '1990-05-15',
description: 'Date of birth (YYYY-MM-DD). Must not be a future date.'
})
@IsDateString()
@Transform(({ value }) => value)
// Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so
// @MaxDate (which requires an actual Date instance) evaluates correctly.
@Transform(({ value }) => (value ? new Date(value) : value))
@IsDate()
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
dateOfBirth: string;
dateOfBirth: Date;
@ApiProperty({
example: 'NATIONAL_ID',

View File

@@ -894,18 +894,17 @@ export class GuestBookingService {
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
if (dto.createAccount && firstPassenger.email && dto.password) {
const guestName = firstPassenger.passengerName ?? 'Guest';
const result = await this.passengerAuthService.register(
const result = await this.passengerAuthService.registerWithPassword(
{
email: firstPassenger.email,
username: firstPassenger.email,
phoneNumber: firstPassenger.phone || `+251900000000`,
name: { en: guestName, am: guestName },
password: dto.password,
confirmPassword: dto.password,
},
req,
);
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true };
}
// Create guest passenger with basic profile