This commit is contained in:
Stephanos A
2026-06-25 20:10:54 +03:00
274 changed files with 16658 additions and 5469 deletions

View File

@@ -63,6 +63,7 @@ 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';
import { TasksModule } from './modules/tasks/tasks.module';
@Module({
imports: [
@@ -131,6 +132,7 @@ import { HealthModule } from './modules/health/health.module';
PackagesModule,
ExcessBaggageModule,
HealthModule,
TasksModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },

View File

@@ -47,6 +47,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
}
// When the thrown body is already a structured object (e.g. { status, message, code }),
// merge it into the envelope so callers receive all custom fields.
const customFields =
typeof messageRaw === 'object' && messageRaw !== null ? messageRaw : {};
response.status(status).json({
success: false,
statusCode: status,
@@ -54,6 +59,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
error: exception instanceof Error ? exception.name : 'Error',
timestamp: new Date().toISOString(),
path: request.url,
...customFields,
});
}
}

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, SetMetadata, BadRequestException, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { Throttle } from '@nestjs/throttler';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
@@ -35,13 +36,13 @@ export class BookingsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const passengerId = req.user?.passengerId;
if (!passengerId) throw new Error('Passenger ID not found in token');
return this.service.findByPassengerId(passengerId, {
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
const iamUserId = req.user?.id;
if (!iamUserId) throw new UnauthorizedException();
return this.service.findByIamUserId(iamUserId, {
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}

View File

@@ -44,6 +44,11 @@ export class BookingsService {
private readonly fareEngine: FareEngineService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
return this.findByPassengerId(passenger.id, filters);
}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;

View File

@@ -44,7 +44,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
// 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');
const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
if (!isBed) return null;
if (name.includes('vip')) return 'VIP_BED';
return 'ECONOMY_BED';

View File

@@ -51,27 +51,30 @@ export class SeatsService {
: 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 mappedSeats = allSeats.map((s: any) => {
const resolvedBedPosition = isBedCoach
? this.resolveBedPosition(s.col, s.bedPosition)
: s.bedPosition;
return {
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,
bedPosition: resolvedBedPosition,
// Bed-specific fields (only when coach is a bed coach)
...(isBedCoach ? {
room_id: `${a.coach.id}-R${s.row}`,
category: bedCategory,
position: this.colToPosition(s.col, a.coach.arrangement),
bed_type: this.bedPositionToType(resolvedBedPosition),
} : {}),
};
});
const base = {
id: a.coach.id,
@@ -116,7 +119,7 @@ export class SeatsService {
private isBedCoach(coachTypeName: string): boolean {
const n = coachTypeName.toLowerCase();
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette');
return n.includes('bed') || n.includes('berth') || n.includes('sleeper') || n.includes('couchette');
}
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
@@ -128,9 +131,23 @@ export class SeatsService {
return 'ECONOMY_BED';
}
// col format: L1, L2, L3, R1, R2, R3
private colToPosition(col: string): 'LEFT' | 'RIGHT' {
return col?.startsWith('R') ? 'RIGHT' : 'LEFT';
// col format: L1, L2, L3, R1, R2, R3 (new) or A, B, C, D (legacy)
// arrangement e.g. "2+2", "3+3", "2+0" → "leftCount+rightCount"
private colToPosition(col: string, arrangement?: string): 'LEFT' | 'RIGHT' | null {
if (!col) return null;
// New named-col format: L1, L2, R1, R2 …
if (/^L\d+$/.test(col)) return 'LEFT';
if (/^R\d+$/.test(col)) return 'RIGHT';
// Legacy single-letter cols (A, B, C, D …): derive from arrangement
const colIndex = col.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, C=2 …
if (arrangement) {
const [leftStr, rightStr] = arrangement.split('+');
const rightCount = parseInt(rightStr ?? '0', 10);
if (rightCount === 0) return 'LEFT'; // single-side berth coach — all LEFT
const leftCount = parseInt(leftStr, 10) || 0;
return colIndex < leftCount ? 'LEFT' : 'RIGHT';
}
return 'LEFT'; // safe default when no arrangement info
}
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
@@ -141,6 +158,22 @@ export class SeatsService {
return map[bedPosition.toLowerCase()] ?? null;
}
// Derives bedPosition from col when the seat was created with legacy A/B/C columns
// (new coaches use L1/L2/L3/R1/R2/R3 and store bedPosition explicitly).
// Col-to-tier mapping: A → lower, B → middle, C → upper, D → upper (4-tier).
private resolveBedPosition(col: string, storedBedPosition: string | null): string | null {
if (storedBedPosition) return storedBedPosition;
const legacyMap: Record<string, string> = { A: 'lower', B: 'middle', C: 'upper', D: 'upper' };
// Also handle numeric suffix in L/R cols: L1→lower, L2→middle, L3→upper
if (/^[LR]\d+$/.test(col)) {
const tier = parseInt(col.slice(1), 10);
if (tier === 1) return 'lower';
if (tier === 2) return 'middle';
return 'upper';
}
return legacyMap[col?.toUpperCase()] ?? null;
}
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -0,0 +1,205 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
/** Minutes before departure at which each action fires. */
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
/** Half-width of the reminder detection window (cron runs every 2 min). */
const REMINDER_WINDOW_MINUTES = 2;
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Africa/Addis_Ababa',
});
}
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsClientService,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
async syncScheduleStatuses() {
const now = new Date();
const [departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: now } },
data: { status: 'EN_ROUTE' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
data: { status: 'ARRIVED' },
}),
]);
if (departed.count > 0 || arrived.count > 0) {
this.logger.log(
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: payment deadline enforcement.
//
// • 3 h before departure → send one SMS reminder to complete payment.
// • 2 h before departure → cancel booking if payment is still pending
// and notify the passenger by SMS.
//
// Example: train departs 08:00
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
// 06:00 → booking auto-cancelled, cancellation SMS sent
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
async enforcePaymentDeadlines() {
const now = new Date();
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
]);
}
// ── 3-hour reminder ───────────────────────────────────────────────────────
private async sendPaymentReminders(now: Date) {
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
const reminderMs = REMINDER_MINUTES * 60 * 1000;
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
} as any,
include: {
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
},
},
});
for (const booking of bookings) {
try {
const dep = booking.schedule.departureAt as Date;
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}) departs at ${fmtTime(dep)}. ` +
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
await this.prisma.booking.update({
where: { id: booking.id },
data: { paymentReminderSentAt: now } as any,
});
this.logger.log(
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
);
} catch (err) {
this.logger.error(
`Reminder failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
schedule: { departureAt: { lte: cutoff } },
},
include: {
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
},
paymentIntent: { select: { method: true } },
},
});
for (const booking of expiredBookings) {
try {
// 1. Release held seats (Journey rows are the occupancy source of truth)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
// 2. Audit record (no refund — payment was never completed)
await this.prisma.bookingCancellation.create({
data: {
bookingId: booking.id,
cancelledBy: 'SYSTEM',
reason: 'Payment not completed before departure deadline',
refundAmount: 0,
refundMethod: booking.paymentIntent?.method ?? 'NONE',
refundStatus: 'NOT_APPLICABLE',
},
}).catch(() => null); // booking may already have a cancellation record
// 3. Mark cancelled
await this.prisma.booking.update({
where: { id: booking.id },
data: { status: 'CANCELLED' },
});
// 4. Notify passenger
const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed before the deadline.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
);
} catch (err) {
this.logger.error(
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (expiredBookings.length > 0) {
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
}
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
@@ -127,12 +127,37 @@ export class TicketsService {
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// No payment intent record at all
if (!booking.paymentIntent) {
throw new HttpException(
{ status: 'error', message: 'Payment not completed', code: 400 },
HttpStatus.BAD_REQUEST,
);
}
// Payment intent exists but not yet succeeded
if (booking.paymentIntent.status !== 'SUCCEEDED') {
throw new HttpException(
{
status: 'error',
message: 'Payment not completed',
code: 400,
detail: `Payment status: ${booking.paymentIntent.status}`,
},
HttpStatus.BAD_REQUEST,
);
}
// Booking not in CONFIRMED state (safety net — should align with SUCCEEDED)
if (booking.status !== 'CONFIRMED') {
const paymentStatus = booking.paymentIntent?.status ?? null;
throw new BadRequestException(
`Payment not completed. Please complete your payment before accessing the ticket. ` +
`Booking status: ${booking.status}` +
(paymentStatus ? `. Payment status: ${paymentStatus}` : ''),
throw new HttpException(
{
status: 'error',
message: 'Payment not completed',
code: 400,
detail: `Booking status: ${booking.status}`,
},
HttpStatus.BAD_REQUEST,
);
}

View File

@@ -59,6 +59,9 @@ export class CompleteVerificationResultDto {
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
gender?: string;
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
userDataSaved?: boolean;
}
export class VerifaydaCallbackDto {

View File

@@ -72,6 +72,7 @@ export interface CompleteVerificationResult {
phoneNumber?: string;
birthdate?: string;
gender?: string;
userDataSaved?: boolean;
}
@Injectable()
@@ -219,8 +220,8 @@ export class VerifaydaService {
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.
// VERIFY — prove identity, save to IAM, return verified attributes.
const { userDataSaved } = await this.upsertIamUser(normalized);
result = {
purpose: 'VERIFY',
verified: true,
@@ -229,6 +230,7 @@ export class VerifaydaService {
phoneNumber: normalized.phoneNumber,
birthdate: normalized.birthdate,
gender: normalized.gender,
userDataSaved,
};
}
@@ -265,13 +267,13 @@ export class VerifaydaService {
}
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
`SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>(
`SELECT verified_by, updated_at, 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 faydaVerified = iam?.verified_by === 'fayda';
const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined;
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
}
@@ -387,18 +389,39 @@ export class VerifaydaService {
}
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
const nameEn = raw['name#en'] as string | undefined;
const nameAm = raw['name#am'] as string | undefined;
const genderEn = raw['gender#en'] as string | undefined;
const genderAm = raw['gender#am'] as string | undefined;
const addressEn = raw['address#en'] as string | undefined;
const addressAm = raw['address#am'] as string | undefined;
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
return {
sub: raw.sub,
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'],
phoneNumber:
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone,
email: raw.email,
gender: raw.gender,
birthdate: raw.birthdate,
picture: raw.picture,
fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm,
phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined,
rawPhoneNumber: rawPhone,
email: raw.email as string | undefined,
gender: genderEn ?? genderAm ?? (raw.gender as string | undefined),
birthdate: raw.birthdate as string | undefined,
picture: raw.picture as string | undefined,
nameEn,
nameAm,
genderEn,
genderAm,
addressEn,
addressAm,
};
}
private standardizePhoneNumber(phone: string): string {
const digits = phone.replace(/\D/g, '');
if (digits.startsWith('251')) return `+${digits}`;
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
return `+${digits}`;
}
// 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.
@@ -411,6 +434,88 @@ export class VerifaydaService {
});
}
private async upsertIamUser(
normalized: NormalizedFaydaUserInfo,
): Promise<{ iamUserId: string | null; userDataSaved: boolean }> {
try {
const iamMetadata = {
sub: normalized.sub,
address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' },
email: normalized.email ?? '',
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
phoneNumber: normalized.rawPhoneNumber ?? '',
};
// Step 1 — already verified with same Fayda sub
const bySub = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
[normalized.sub],
);
if (bySub.length > 0) {
return { iamUserId: bySub[0].id, userDataSaved: true };
}
// Step 2 — existing user by phone or email, not yet Fayda-verified
const conditions: string[] = [];
const params: unknown[] = [];
if (normalized.phoneNumber) {
params.push(normalized.phoneNumber);
conditions.push(`phone_number = $${params.length}`);
}
if (normalized.email) {
params.push(normalized.email);
conditions.push(`email = $${params.length}`);
}
if (conditions.length > 0) {
const byContact = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`,
params,
);
if (byContact.length > 0) {
const existingId = byContact[0].id;
await this.dataSource.query(
`UPDATE iam.users
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
verified_by = 'fayda',
updated_at = NOW()
WHERE id = $2`,
[JSON.stringify(iamMetadata), existingId],
);
return { iamUserId: existingId, userDataSaved: true };
}
}
// Step 3 — new user
const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' };
const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub;
const inserted = await this.dataSource.query<{ id: string }[]>(
`INSERT INTO iam.users (
id, name, username, email, phone_number, metadata,
user_type, status, is_active, has_set_password,
is_phone_number_verified, verified_by,
created_at, updated_at
) VALUES (
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
'individual', 'accepted', true, false,
false, 'fayda',
NOW(), NOW()
) RETURNING id`,
[
JSON.stringify(name),
username,
normalized.email ?? null,
normalized.phoneNumber ?? null,
JSON.stringify(iamMetadata),
],
);
return { iamUserId: inserted[0].id, userDataSaved: true };
} catch (err) {
this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`);
return { iamUserId: null, userDataSaved: false };
}
}
private async markSessionFailed(
state: string,
errorCode: string,

View File

@@ -27,10 +27,19 @@ export interface FaydaUserInfo {
export interface NormalizedFaydaUserInfo {
sub: string;
// Convenience / display fields
fullName?: string;
phoneNumber?: string;
phoneNumber?: string; // standardized e.g. +251911234567
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
// Raw localized fields — preserved for IAM-identical writes
nameEn?: string;
nameAm?: string;
genderEn?: string;
genderAm?: string;
addressEn?: string;
addressAm?: string;
rawPhoneNumber?: string; // unstandardized, stored in IAM metadata
}