mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 13:40:57 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user