diff --git a/apps/bff/scripts/check-sim-status.mjs b/apps/bff/scripts/check-sim-status.mjs index 756e961c..56c98f15 100644 --- a/apps/bff/scripts/check-sim-status.mjs +++ b/apps/bff/scripts/check-sim-status.mjs @@ -4,11 +4,11 @@ * Usage: node scripts/check-sim-status.mjs */ -const account = process.argv[2] || '02000002470010'; +const account = process.argv[2] || "02000002470010"; -const FREEBIT_BASE_URL = 'https://i1-q.mvno.net/emptool/api'; -const FREEBIT_OEM_ID = 'PASI'; -const FREEBIT_OEM_KEY = '6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5'; +const FREEBIT_BASE_URL = "https://i1-q.mvno.net/emptool/api"; +const FREEBIT_OEM_ID = "PASI"; +const FREEBIT_OEM_KEY = "6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5"; async function getAuthKey() { const request = { @@ -17,8 +17,8 @@ async function getAuthKey() { }; const response = await fetch(`${FREEBIT_BASE_URL}/authOem/`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${JSON.stringify(request)}`, }); @@ -27,7 +27,7 @@ async function getAuthKey() { } const data = await response.json(); - if (data.resultCode !== 100 && data.resultCode !== '100') { + if (data.resultCode !== 100 && data.resultCode !== "100") { throw new Error(`Auth failed: ${data.status?.message || JSON.stringify(data)}`); } @@ -38,8 +38,8 @@ async function getTrafficInfo(authKey, account) { const request = { authKey, account }; const response = await fetch(`${FREEBIT_BASE_URL}/mvno/getTrafficInfo/`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${JSON.stringify(request)}`, }); return response.json(); @@ -48,13 +48,13 @@ async function getTrafficInfo(authKey, account) { async function getAccountDetails(authKey, account) { const request = { authKey, - version: '2', - requestDatas: [{ kind: 'MVNO', account }], + version: "2", + requestDatas: [{ kind: "MVNO", account }], }; const response = await fetch(`${FREEBIT_BASE_URL}/master/getAcnt/`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${JSON.stringify(request)}`, }); return response.json(); @@ -65,20 +65,19 @@ async function main() { try { const authKey = await getAuthKey(); - console.log('✓ Authenticated with Freebit\n'); + console.log("✓ Authenticated with Freebit\n"); // Try getTrafficInfo first (simpler) - console.log('--- Traffic Info (/mvno/getTrafficInfo/) ---'); + console.log("--- Traffic Info (/mvno/getTrafficInfo/) ---"); const trafficInfo = await getTrafficInfo(authKey, account); console.log(JSON.stringify(trafficInfo, null, 2)); // Try getAcnt for full details - console.log('\n--- Account Details (/master/getAcnt/) ---'); + console.log("\n--- Account Details (/master/getAcnt/) ---"); const details = await getAccountDetails(authKey, account); console.log(JSON.stringify(details, null, 2)); - } catch (error) { - console.error('❌ Error:', error.message); + console.error("❌ Error:", error.message); } } diff --git a/apps/bff/sim-api-test-log.csv b/apps/bff/sim-api-test-log.csv index 7ece45ff..2fa55d66 100644 --- a/apps/bff/sim-api-test-log.csv +++ b/apps/bff/sim-api-test-log.csv @@ -1,90 +1 @@ Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info -2026-01-31T02:21:03.485Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T02:21:07.599Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:11:11.315Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:11:15.556Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:11:53.182Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:32:18.526Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:32:22.394Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:32:37.351Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:32:41.487Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:48:41.057Z,/mvno/changePlan/,POST,02000331144508,02000331144508,"{""account"":""02000331144508"",""planCode"":""PASI_5G"",""runTime"":""20260301""}",Error: 211,API Error: NG,API Error: NG -2026-01-31T04:49:40.396Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:49:44.170Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:50:51.053Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T04:50:56.134Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T05:04:11.957Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T05:04:16.274Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T05:11:55.749Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T05:11:59.557Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T05:18:00.675Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T05:18:06.042Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T08:37:08.201Z,/master/addSpec/,POST,02000331144508,02000331144508,"{""account"":""02000331144508"",""quota"":1024000}",Error: 200,API Error: Bad Request,API Error: Bad Request -2026-01-31T08:45:14.336Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T08:45:18.452Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T08:45:40.760Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T08:45:47.572Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG -2026-01-31T08:49:32.767Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T08:49:32.948Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T08:50:04.739Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T08:50:05.899Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T08:55:27.913Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T08:55:28.280Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T08:55:39.246Z,/master/addSpec/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""quota"":1024000}",Error: 200,API Error: Bad Request,API Error: Bad Request -2026-01-31T09:03:45.084Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:03:45.276Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:04:02.612Z,/master/addSpec/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""quota"":1000,""kind"":""MVNO""}",Success,,OK -2026-01-31T09:12:19.280Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:12:19.508Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:12:25.347Z,/mvno/changePlan/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""planCode"":""PASI_10G"",""runTime"":""20260301""}",Success,,OK -2026-01-31T09:13:15.309Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:13:15.522Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:21:56.856Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:21:57.041Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:23:40.211Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:24:26.592Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:24:26.830Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:24:49.713Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:24:49.910Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:25:40.613Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:25:53.426Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:26:05.126Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:26:18.482Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-01-31T09:26:57.215Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T01:48:36.804Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T01:48:37.013Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T01:49:41.283Z,/mvno/changePlan/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""planCode"":""PASI_10G"",""runTime"":""20260301""}",Success,,OK -2026-02-02T01:50:58.940Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T01:50:59.121Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T01:51:07.911Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:49:01.626Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:49:01.781Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:49:04.551Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-02-02T02:49:04.804Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:52:39.440Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:52:39.696Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:52:43.402Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-02-02T02:52:43.557Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:52:50.419Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-02-02T02:52:50.595Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:52:58.616Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:52:58.762Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T02:53:01.434Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-02-02T02:53:01.580Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T03:00:20.821Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T03:00:21.068Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T03:00:25.799Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-02-02T03:00:26.012Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:14:20.988Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:14:21.197Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:14:23.599Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request -2026-02-02T04:14:23.805Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:17:24.519Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:17:24.698Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:27:46.948Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:27:47.130Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-02T04:27:59.150Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Success,,OK -2026-02-03T02:22:24.012Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-03T02:22:24.263Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK -2026-02-03T02:44:57.675Z,/mvno/semiblack/addAcnt/,POST,02000002470010,02000002470010,"{""createType"":""new"",""account"":""02000002470010"",""productNumber"":""PT0220024700100"",""planCode"":""PASI_5G"",""shipDate"":""20260203"",""mnp"":{""method"":""10""},""globalIp"":""20"",""aladinOperated"":""20""}",Success,,OK -2026-02-03T02:55:57.379Z,/mvno/semiblack/addAcnt/,POST,07000240050,07000240050,"{""createType"":""new"",""account"":""07000240050"",""productNumber"":""PT0270002400500"",""planCode"":""PASI_10G"",""shipDate"":""20260203"",""mnp"":{""method"":""10""},""globalIp"":""20"",""aladinOperated"":""20""}",Success,,OK diff --git a/apps/bff/src/core/config/auth-dev.config.ts b/apps/bff/src/core/config/auth-dev.config.ts index 99d94ce8..953ae9dc 100644 --- a/apps/bff/src/core/config/auth-dev.config.ts +++ b/apps/bff/src/core/config/auth-dev.config.ts @@ -9,6 +9,7 @@ export interface DevAuthConfig { disableAccountLocking: boolean; enableDebugLogs: boolean; simplifiedErrorMessages: boolean; + skipOtp: boolean; } export const createDevAuthConfig = (): DevAuthConfig => { @@ -29,6 +30,9 @@ export const createDevAuthConfig = (): DevAuthConfig => { // Show detailed error messages in development simplifiedErrorMessages: isDevelopment, + + // Skip OTP verification in development (login directly after credentials) + skipOtp: isDevelopment && process.env["SKIP_OTP"] === "true", }; }; diff --git a/apps/bff/src/core/http/exception.filter.ts b/apps/bff/src/core/http/exception.filter.ts index 1e0baabd..f6d59ac3 100644 --- a/apps/bff/src/core/http/exception.filter.ts +++ b/apps/bff/src/core/http/exception.filter.ts @@ -232,9 +232,9 @@ export class UnifiedExceptionFilter implements ExceptionFilter { userAgent: typeof userAgentHeader === "string" ? userAgentHeader - : (Array.isArray(userAgentHeader) + : Array.isArray(userAgentHeader) ? userAgentHeader[0] - : undefined), + : undefined, ip: request.ip, }; } diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index badf35b4..053da70c 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -354,17 +354,17 @@ export class FreebitClientService { const timestamp = this.testTracker.getCurrentTimestamp(); const resultCode = response?.resultCode ? String(response.resultCode) - : (error instanceof FreebitError + : error instanceof FreebitError ? String(error.resultCode || "ERROR") - : "ERROR"); + : "ERROR"; const statusMessage = response?.status?.message || (error instanceof FreebitError ? error.message - : (error + : error ? extractErrorMessage(error) - : "Success")); + : "Success"); await this.testTracker.logApiCall({ timestamp, diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 0355b61a..b4d3f7bc 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -36,6 +36,7 @@ import { REFRESH_COOKIE_PATH, TOKEN_TYPE, } from "./utils/auth-cookie.util.js"; +import { devAuthConfig } from "@bff/core/config/auth-dev.config.js"; // Import Zod schemas from domain import { @@ -130,6 +131,21 @@ export class AuthController { @Req() req: RequestWithUser & RequestWithRateLimit, @Res({ passthrough: true }) res: Response ) { + this.applyAuthRateLimitHeaders(req, res); + + // In dev mode with SKIP_OTP=true, skip OTP and complete login directly + if (devAuthConfig.skipOtp) { + const loginResult = await this.authOrchestrator.completeLogin( + { id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" }, + req + ); + setAuthCookies(res, loginResult.tokens); + return { + user: loginResult.user, + session: buildSessionInfo(loginResult.tokens), + }; + } + // Credentials validated by LocalAuthGuard - now initiate OTP const fingerprint = getRequestFingerprint(req); const otpResult = await this.loginOtpWorkflow.initiateOtp( @@ -141,8 +157,6 @@ export class AuthController { fingerprint ); - this.applyAuthRateLimitHeaders(req, res); - // Return OTP required response - no tokens issued yet return { requiresOtp: true, diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index d3c12431..ea33c38e 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -44,7 +44,7 @@ export class CheckoutService { private summarizeSelectionsForLog(selections: OrderSelections): Record { const addons = this.collectAddonRefs(selections); const normalizeBool = (value?: string) => - value === "true" ? true : (value === "false" ? false : undefined); + value === "true" ? true : value === "false" ? false : undefined; return { planSku: selections.planSku, diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 1f49ccd6..085b3b69 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -84,9 +84,9 @@ export class OrderValidator { const productContainer = products.products?.product; const existing = Array.isArray(productContainer) ? productContainer - : (productContainer + : productContainer ? [productContainer] - : []); + : []; // Check for active Internet products const activeInternetProducts = existing.filter((product: WhmcsProduct) => { diff --git a/apps/bff/src/modules/orders/validators/internet-order.validator.ts b/apps/bff/src/modules/orders/validators/internet-order.validator.ts index 4c99d6a8..38adee14 100644 --- a/apps/bff/src/modules/orders/validators/internet-order.validator.ts +++ b/apps/bff/src/modules/orders/validators/internet-order.validator.ts @@ -77,9 +77,9 @@ export class InternetOrderValidator { const productContainer = products.products?.product; const existing = Array.isArray(productContainer) ? productContainer - : (productContainer + : productContainer ? [productContainer] - : []); + : []; // Check for active Internet products const activeInternetProducts = existing.filter((product: WhmcsProduct) => { diff --git a/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts index fe756325..9182e686 100644 --- a/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts +++ b/apps/bff/src/modules/shared/workflow/workflow-case-manager.service.ts @@ -52,9 +52,9 @@ export class WorkflowCaseManager { : null; const opportunityStatus = opportunityId - ? (opportunityCreated + ? opportunityCreated ? "Created new opportunity for this order" - : "Linked to existing opportunity") + : "Linked to existing opportunity" : "No opportunity linked"; const description = this.buildDescription([ diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index 4c755ffb..4568ee0d 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -77,9 +77,9 @@ export class InternetCancellationService { const productContainer = productsResponse.products?.product; const products = Array.isArray(productContainer) ? productContainer - : (productContainer + : productContainer ? [productContainer] - : []); + : []; const subscription = products.find( (p: { id?: number | string }) => Number(p.id) === subscriptionId diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts index 15d5456c..fc5e25eb 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts @@ -107,9 +107,9 @@ export class SimValidationService { // Account extraction result extractedAccount, accountSource: extractedAccount - ? (subscription.domain + ? subscription.domain ? "domain field" - : "custom field or order number") + : "custom field or order number" : "NOT FOUND - check fields below", // All custom fields for debugging customFieldKeys: Object.keys(subscription.customFields || {}), diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index 769f893d..ab43549a 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -93,9 +93,9 @@ export class ResidenceCardService { const reviewerNotes = typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0 ? rejectionRaw.trim() - : (typeof noteRaw === "string" && noteRaw.trim().length > 0 + : typeof noteRaw === "string" && noteRaw.trim().length > 0 ? noteRaw.trim() - : null); + : null; return residenceCardVerificationSchema.parse({ status, diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index 018d5b77..54e7685d 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -34,8 +34,11 @@ const nextConfig = { config.watchOptions = { ...config.watchOptions, ignored: config.watchOptions?.ignored - ? [...(Array.isArray(config.watchOptions.ignored) ? config.watchOptions.ignored : [config.watchOptions.ignored])] - .filter(p => !String(p).includes("packages/domain")) + ? [ + ...(Array.isArray(config.watchOptions.ignored) + ? config.watchOptions.ignored + : [config.watchOptions.ignored]), + ].filter(p => !String(p).includes("packages/domain")) : ["**/node_modules/**"], }; // Add domain dist to snapshot managed paths for better change detection diff --git a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx index 49a8eecc..9dafae15 100644 --- a/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx +++ b/apps/portal/src/components/molecules/OtpInput/OtpInput.tsx @@ -149,9 +149,9 @@ export function OtpInput({ "disabled:opacity-50 disabled:cursor-not-allowed", error ? "border-danger focus:ring-danger focus:border-danger" - : (activeIndex === index + : activeIndex === index ? "border-primary" - : "border-border hover:border-muted-foreground/50") + : "border-border hover:border-muted-foreground/50" )} aria-label={`Digit ${index + 1}`} /> diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index d60ee067..cb991c0a 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -216,9 +216,9 @@ export function AppShell({ children }: AppShellProps) { - ) : (isAuthReady ? ( + ) : isAuthReady ? ( children - ) : null)} + ) : null} diff --git a/apps/portal/src/features/account/components/AddressCard.tsx b/apps/portal/src/features/account/components/AddressCard.tsx index 95956178..56b9bd27 100644 --- a/apps/portal/src/features/account/components/AddressCard.tsx +++ b/apps/portal/src/features/account/components/AddressCard.tsx @@ -126,11 +126,11 @@ export function AddressCard({ )} - ) : (hasAddress ? ( + ) : hasAddress ? ( ) : ( - ))} + )} ); diff --git a/apps/portal/src/features/address/components/AddressStepJapan.tsx b/apps/portal/src/features/address/components/AddressStepJapan.tsx index b040b6af..87528fb1 100644 --- a/apps/portal/src/features/address/components/AddressStepJapan.tsx +++ b/apps/portal/src/features/address/components/AddressStepJapan.tsx @@ -119,9 +119,9 @@ function fromLegacyFormat(address: LegacyAddressData): PartialJapanAddressFormDa // For new users, leave it undefined so they must explicitly choose const hasExistingAddress = address.postcode || address.state || address.city; const residenceType = hasExistingAddress - ? (roomNumber + ? roomNumber ? RESIDENCE_TYPE.APARTMENT - : RESIDENCE_TYPE.HOUSE) + : RESIDENCE_TYPE.HOUSE : undefined; return { diff --git a/apps/portal/src/features/address/components/JapanAddressForm.tsx b/apps/portal/src/features/address/components/JapanAddressForm.tsx index cc011c58..f2b9ed41 100644 --- a/apps/portal/src/features/address/components/JapanAddressForm.tsx +++ b/apps/portal/src/features/address/components/JapanAddressForm.tsx @@ -336,9 +336,9 @@ export function JapanAddressForm({ required helperText={ form.address.streetAddress.trim() - ? (streetAddressError + ? streetAddressError ? undefined - : "Valid format") + : "Valid format" : "Enter chome-banchi-go (e.g., 1-5-3)" } > diff --git a/apps/portal/src/features/address/components/ProgressIndicator.tsx b/apps/portal/src/features/address/components/ProgressIndicator.tsx index c789349b..48a3f4a8 100644 --- a/apps/portal/src/features/address/components/ProgressIndicator.tsx +++ b/apps/portal/src/features/address/components/ProgressIndicator.tsx @@ -23,9 +23,9 @@ export function ProgressIndicator({ currentStep, totalSteps }: ProgressIndicator "h-1 rounded-full transition-all duration-500", i < currentStep ? "bg-primary flex-[2]" - : (i === currentStep + : i === currentStep ? "bg-primary/40 flex-[2] animate-pulse" - : "bg-border flex-1") + : "bg-border flex-1" )} /> ))} diff --git a/apps/portal/src/features/auth/stores/auth.store.ts b/apps/portal/src/features/auth/stores/auth.store.ts index 209adeed..0065c6ad 100644 --- a/apps/portal/src/features/auth/stores/auth.store.ts +++ b/apps/portal/src/features/auth/stores/auth.store.ts @@ -11,6 +11,7 @@ import { authResponseSchema, checkPasswordNeededResponseSchema, loginOtpRequiredResponseSchema, + loginResponseSchema, type AuthSession, type CheckPasswordNeededResponse, type LoginRequest, @@ -40,8 +41,10 @@ export interface AuthState { error: string | null; hasCheckedAuth: boolean; - // Two-step login with OTP - initiateLogin: (credentials: LoginRequest) => Promise; + // Two-step login with OTP (or direct login in dev mode with SKIP_OTP) + initiateLogin: ( + credentials: LoginRequest + ) => Promise; verifyLoginOtp: (sessionToken: string, code: string) => Promise; // Legacy login (kept for backward compatibility during migration) login: (credentials: LoginRequest) => Promise; @@ -195,6 +198,7 @@ export const useAuthStore = create()((set, get) => { /** * Step 1 of two-step login: Validate credentials and initiate OTP * Returns OTP session info for the frontend to display OTP input + * In dev mode with SKIP_OTP=true, returns direct login result */ initiateLogin: async credentials => { set({ loading: true, error: null }); @@ -203,12 +207,20 @@ export const useAuthStore = create()((set, get) => { body: credentials, disableCsrf: true, // Public auth endpoint, exempt from CSRF }); - const parsed = loginOtpRequiredResponseSchema.safeParse(response.data); + const parsed = loginResponseSchema.safeParse(response.data); if (!parsed.success) { throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed"); } - set({ loading: false }); - return parsed.data; + + // Check if OTP is required or if we got a direct login (dev mode) + if ("requiresOtp" in parsed.data && parsed.data.requiresOtp === true) { + set({ loading: false }); + return parsed.data; + } + + // Direct login - apply auth response and signal completion + applyAuthResponse(parsed.data as AuthResponseData); + return { requiresOtp: false as const }; } catch (error) { const parsed = parseError(error); set({ loading: false, error: parsed.message, isAuthenticated: false }); diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index 3c912580..de1d5404 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -144,7 +144,7 @@ export function PaymentMethodsContainer() { - ) : (paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? ( + ) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? (
@@ -214,7 +214,7 @@ export function PaymentMethodsContainer() {
)}
- ))} + )}
diff --git a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx index c13b47fb..2e5b084b 100644 --- a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx @@ -132,11 +132,11 @@ export function CheckoutStatusBanners({

{eligibility.notes ? (

{eligibility.notes}

- ) : (eligibility.requestedAt ? ( + ) : eligibility.requestedAt ? (

Last updated: {new Date(eligibility.requestedAt).toLocaleString()}

- ) : null)} + ) : null} diff --git a/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx index be52f925..e13bd4b2 100644 --- a/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx +++ b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx @@ -254,11 +254,11 @@ function NotSubmittedContent({
Rejection note
{reviewerNotes}
- ) : (isRejected ? ( + ) : isRejected ? (

Your document couldn't be approved. Please upload a new file to continue.

- ) : null)} + ) : null}

Upload a JPG, PNG, or PDF (max 5MB). We'll verify it before activating SIM service. diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 0e79e182..8ae3395f 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -1341,14 +1341,14 @@ export function PublicLandingView() { Sending... - ) : (submitStatus === "success" ? ( + ) : submitStatus === "success" ? ( <> Sent! ) : ( "Submit" - ))} + )} diff --git a/apps/portal/src/features/notifications/components/NotificationDropdown.tsx b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx index 8862775e..930c7340 100644 --- a/apps/portal/src/features/notifications/components/NotificationDropdown.tsx +++ b/apps/portal/src/features/notifications/components/NotificationDropdown.tsx @@ -67,7 +67,7 @@ export const NotificationDropdown = memo(function NotificationDropdown({

- ) : (notifications.length === 0 ? ( + ) : notifications.length === 0 ? (

No notifications yet

@@ -86,7 +86,7 @@ export const NotificationDropdown = memo(function NotificationDropdown({ /> ))}
- ))} + )}
{/* Footer */} diff --git a/apps/portal/src/features/services/components/base/AddressForm.tsx b/apps/portal/src/features/services/components/base/AddressForm.tsx index c28a6a41..4e3d72e2 100644 --- a/apps/portal/src/features/services/components/base/AddressForm.tsx +++ b/apps/portal/src/features/services/components/base/AddressForm.tsx @@ -287,9 +287,9 @@ export function AddressForm({ const containerClasses = variant === "inline" ? "" - : (variant === "compact" + : variant === "compact" ? "p-4 bg-gray-50 rounded-lg border border-gray-200" - : "p-6 bg-white border border-gray-200 rounded-lg"); + : "p-6 bg-white border border-gray-200 rounded-lg"; // Get all validation errors const allErrors = Object.values(form.errors).filter(Boolean) as string[]; diff --git a/apps/portal/src/features/services/components/base/OrderSummary.tsx b/apps/portal/src/features/services/components/base/OrderSummary.tsx index b1d8dc38..04b0958c 100644 --- a/apps/portal/src/features/services/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/services/components/base/OrderSummary.tsx @@ -261,7 +261,7 @@ export function OrderSummary({ ) : null} - ) : (onContinue ? ( + ) : onContinue ? ( - ) : null)} + ) : null} )} diff --git a/apps/portal/src/features/services/components/base/ProductCard.tsx b/apps/portal/src/features/services/components/base/ProductCard.tsx index e39c45c6..feb73ea5 100644 --- a/apps/portal/src/features/services/components/base/ProductCard.tsx +++ b/apps/portal/src/features/services/components/base/ProductCard.tsx @@ -160,7 +160,7 @@ export function ProductCard({ > {actionLabel} - ) : (onClick ? ( + ) : onClick ? ( - ) : null)} + ) : null} {/* Custom footer */} diff --git a/apps/portal/src/features/services/components/internet/InstallationOptions.tsx b/apps/portal/src/features/services/components/internet/InstallationOptions.tsx index 25d5fbb7..1fb6944d 100644 --- a/apps/portal/src/features/services/components/internet/InstallationOptions.tsx +++ b/apps/portal/src/features/services/components/internet/InstallationOptions.tsx @@ -37,9 +37,9 @@ export function InstallationOptions({ installation.description || (installationTerm === "12-Month" ? "Spread the installation fee across 12 monthly payments." - : (installationTerm === "24-Month" + : installationTerm === "24-Month" ? "Spread the installation fee across 24 monthly payments." - : "Pay the full installation fee in one payment.")); + : "Pay the full installation fee in one payment."); return ( - ))} + )} ))} diff --git a/apps/portal/src/features/services/components/sim/DeviceCompatibility.tsx b/apps/portal/src/features/services/components/sim/DeviceCompatibility.tsx index 540c4bbb..85935d4d 100644 --- a/apps/portal/src/features/services/components/sim/DeviceCompatibility.tsx +++ b/apps/portal/src/features/services/components/sim/DeviceCompatibility.tsx @@ -255,7 +255,7 @@ export function DeviceCompatibility() {

)} - ) : (showNoResults ? ( + ) : showNoResults ? (
@@ -269,7 +269,7 @@ export function DeviceCompatibility() { to verify compatibility.

- ) : null)} + ) : null}
)} diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index f51eecf8..58dc87cb 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -266,9 +266,9 @@ export function SimPlansContent({ const tabPlans = activeTab === "data-voice" ? plansByType.DataSmsVoice - : (activeTab === "data-only" + : activeTab === "data-only" ? plansByType.DataOnly - : plansByType.VoiceOnly); + : plansByType.VoiceOnly; const regularPlans = tabPlans.filter(p => !p.simHasFamilyDiscount); const familyPlans = tabPlans.filter(p => p.simHasFamilyDiscount); diff --git a/apps/portal/src/features/services/hooks/useConfigureParams.ts b/apps/portal/src/features/services/hooks/useConfigureParams.ts index 9b74a6f3..dd71dba3 100644 --- a/apps/portal/src/features/services/hooks/useConfigureParams.ts +++ b/apps/portal/src/features/services/hooks/useConfigureParams.ts @@ -64,9 +64,9 @@ export function useInternetConfigureParams() { .split(",") .map(s => s.trim()) .filter(Boolean) - : (addonSkuParams.length > 0 + : addonSkuParams.length > 0 ? addonSkuParams - : []); + : []; return { accessMode, diff --git a/apps/portal/src/features/services/views/InternetEligibilityRequest.tsx b/apps/portal/src/features/services/views/InternetEligibilityRequest.tsx index 619e28d1..9a1bf1e4 100644 --- a/apps/portal/src/features/services/views/InternetEligibilityRequest.tsx +++ b/apps/portal/src/features/services/views/InternetEligibilityRequest.tsx @@ -204,7 +204,7 @@ export function InternetEligibilityRequestView() {
{planLoading ? (
Loading selected plan…
- ) : (plan ? ( + ) : plan ? (

Selected plan

@@ -212,7 +212,7 @@ export function InternetEligibilityRequestView() {
- ) : null)} + ) : null}
diff --git a/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx b/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx index e17a069f..aa7c2de0 100644 --- a/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx +++ b/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx @@ -68,9 +68,9 @@ export function PublicEligibilityCheckView() { step === "success" ? (hasAccount ? "Account Created" : "Request Submitted") : currentMeta.title; const description = step === "success" - ? (hasAccount + ? hasAccount ? "Your account is ready and eligibility check is in progress." - : "Your availability check request has been submitted.") + : "Your availability check request has been submitted." : currentMeta.description; return ( diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index a9157a83..76b5d9fe 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -977,7 +977,7 @@ export function PublicInternetPlansContent({
{isLoading ? ( - ) : (consolidatedPlanData ? ( + ) : consolidatedPlanData ? ( - ) : null)} + ) : null}
{/* Available Plans - Expandable cards by offering type */} diff --git a/apps/portal/src/features/subscriptions/components/sim/ReissueSimModal.tsx b/apps/portal/src/features/subscriptions/components/sim/ReissueSimModal.tsx index bd68e789..d6b9a978 100644 --- a/apps/portal/src/features/subscriptions/components/sim/ReissueSimModal.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/ReissueSimModal.tsx @@ -79,9 +79,9 @@ export function ReissueSimModal({ } catch (error: unknown) { const message = process.env.NODE_ENV === "development" - ? (error instanceof Error + ? error instanceof Error ? error.message - : "Failed to submit reissue request") + : "Failed to submit reissue request" : "Failed to submit reissue request. Please try again."; onError(message); } finally { diff --git a/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx b/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx index 208ec8ef..8319b210 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx @@ -388,9 +388,9 @@ function useSimActionsState(subscriptionId: number, onCancelSuccess?: () => void } catch (err: unknown) { setError( process.env.NODE_ENV === "development" - ? (err instanceof Error + ? err instanceof Error ? err.message - : "Failed to cancel SIM service") + : "Failed to cancel SIM service" : "Unable to cancel SIM service right now. Please try again." ); } finally { diff --git a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx index a2e5d32c..adcec8df 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx @@ -108,9 +108,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro } else { setError( process.env.NODE_ENV === "development" - ? (err instanceof Error + ? err instanceof Error ? err.message - : "Failed to load SIM information") + : "Failed to load SIM information" : "Unable to load SIM information right now. Please try again." ); } @@ -252,9 +252,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro const remainingMB = simInfo.details.remainingQuotaMb.toFixed(1); const usedMB = simInfo.usage?.monthlyUsageMb ? simInfo.usage.monthlyUsageMb.toFixed(2) - : (simInfo.usage?.todayUsageMb + : simInfo.usage?.todayUsageMb ? simInfo.usage.todayUsageMb.toFixed(2) - : "0.00"); + : "0.00"; // Calculate percentage for circle const totalMB = Number.parseFloat(remainingMB) + Number.parseFloat(usedMB); diff --git a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx index 80d4c72f..f7c784a7 100644 --- a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx +++ b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx @@ -140,9 +140,9 @@ export function CancelSubscriptionContainer() { } catch (e: unknown) { setError( process.env.NODE_ENV === "development" - ? (e instanceof Error + ? e instanceof Error ? e.message - : "Failed to load cancellation information") + : "Failed to load cancellation information" : "Unable to load cancellation information right now. Please try again." ); } finally { @@ -176,9 +176,9 @@ export function CancelSubscriptionContainer() { } catch (e: unknown) { setFormError( process.env.NODE_ENV === "development" - ? (e instanceof Error + ? e instanceof Error ? e.message - : "Failed to submit cancellation") + : "Failed to submit cancellation" : "Unable to submit your cancellation right now. Please try again." ); } finally { diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index 049cbb89..7a4f84bb 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -32,9 +32,9 @@ export function SimChangePlanContainer() { } catch (e: unknown) { setError( process.env.NODE_ENV === "development" - ? (e instanceof Error + ? e instanceof Error ? e.message - : "Failed to load available plans") + : "Failed to load available plans" : "Unable to load available plans right now. Please try again." ); } finally { @@ -66,9 +66,9 @@ export function SimChangePlanContainer() { } catch (e: unknown) { setError( process.env.NODE_ENV === "development" - ? (e instanceof Error + ? e instanceof Error ? e.message - : "Failed to change plan") + : "Failed to change plan" : "Unable to submit your plan change right now. Please try again." ); } finally { diff --git a/apps/portal/src/features/subscriptions/views/SimReissue.tsx b/apps/portal/src/features/subscriptions/views/SimReissue.tsx index 7d9e5725..cc035aaa 100644 --- a/apps/portal/src/features/subscriptions/views/SimReissue.tsx +++ b/apps/portal/src/features/subscriptions/views/SimReissue.tsx @@ -37,9 +37,9 @@ export function SimReissueContainer() { } catch (e: unknown) { setError( process.env.NODE_ENV === "development" - ? (e instanceof Error + ? e instanceof Error ? e.message - : "Failed to load SIM details") + : "Failed to load SIM details" : "Unable to load SIM details right now. Please try again." ); } finally { @@ -85,9 +85,9 @@ export function SimReissueContainer() { } catch (e: unknown) { setError( process.env.NODE_ENV === "development" - ? (e instanceof Error + ? e instanceof Error ? e.message - : "Failed to submit reissue request") + : "Failed to submit reissue request" : "Unable to submit your request right now. Please try again." ); } finally { diff --git a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx index 6b86ed5d..2d94784c 100644 --- a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx +++ b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx @@ -54,9 +54,9 @@ export function SimTopUpContainer() { } catch (e: unknown) { setError( process.env.NODE_ENV === "development" - ? (e instanceof Error + ? e instanceof Error ? e.message - : "Failed to submit top-up") + : "Failed to submit top-up" : "Unable to submit your top-up right now. Please try again." ); } finally { diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index 83c53472..8488afcd 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -57,9 +57,9 @@ export function SubscriptionDetailContainer() { // Show error message (only when we have an error, not during loading) const pageError = error - ? (process.env.NODE_ENV === "development" && error instanceof Error + ? process.env.NODE_ENV === "development" && error instanceof Error ? error.message - : "Unable to load subscription details. Please try again.") + : "Unable to load subscription details. Please try again." : null; const productNameLower = subscription?.productName?.toLowerCase() ?? ""; diff --git a/apps/portal/src/features/support/views/NewSupportCaseView.tsx b/apps/portal/src/features/support/views/NewSupportCaseView.tsx index 6867d4fd..991cb24d 100644 --- a/apps/portal/src/features/support/views/NewSupportCaseView.tsx +++ b/apps/portal/src/features/support/views/NewSupportCaseView.tsx @@ -42,9 +42,9 @@ export function NewSupportCaseView() { } catch (err) { setError( process.env.NODE_ENV === "development" - ? (err instanceof Error + ? err instanceof Error ? err.message - : "Failed to create support case") + : "Failed to create support case" : "Unable to create your support case right now. Please try again." ); } diff --git a/apps/portal/src/features/support/views/SupportCasesView.tsx b/apps/portal/src/features/support/views/SupportCasesView.tsx index b61e3038..2d3bb047 100644 --- a/apps/portal/src/features/support/views/SupportCasesView.tsx +++ b/apps/portal/src/features/support/views/SupportCasesView.tsx @@ -212,7 +212,7 @@ export function SupportCasesView() { ))} - ) : (hasActiveFilters ? ( + ) : hasActiveFilters ? ( @@ -228,7 +228,7 @@ export function SupportCasesView() { }} /> - ))} + )} ); } diff --git a/docs/development/auth/development-setup.md b/docs/development/auth/development-setup.md index 7ca9fb8d..90630f72 100644 --- a/docs/development/auth/development-setup.md +++ b/docs/development/auth/development-setup.md @@ -29,6 +29,9 @@ DISABLE_CSRF=true DISABLE_RATE_LIMIT=true DISABLE_ACCOUNT_LOCKING=true +# Skip OTP verification during login (direct login after credentials) +SKIP_OTP=true + # Show detailed validation errors in responses EXPOSE_VALIDATION_ERRORS=true diff --git a/docs/refactoring/orchestrator-refactoring-plan.md b/docs/refactoring/orchestrator-refactoring-plan.md deleted file mode 100644 index a828cce6..00000000 --- a/docs/refactoring/orchestrator-refactoring-plan.md +++ /dev/null @@ -1,729 +0,0 @@ -# Orchestrator Refactoring Plan - -## Overview - -This document outlines the refactoring plan for BFF orchestrators to align with enterprise-grade code structure patterns used by companies like Google, Amazon, and Microsoft. - -**Date**: 2026-02-03 -**Branch**: alt-design (post-merge from main) - ---- - -## Orchestrator Assessment Summary - -| Orchestrator | Lines | Status | Priority | -| ------------------------------------------- | ----- | -------------------- | -------- | -| `order-fulfillment-orchestrator.service.ts` | ~990 | ❌ Needs Refactoring | **HIGH** | -| `subscriptions-orchestrator.service.ts` | ~475 | ⚠️ Minor Issues | LOW | -| `auth-orchestrator.service.ts` | ~324 | ✅ Mostly Good | LOW | -| `order-orchestrator.service.ts` | ~270 | ✅ Good | NONE | -| `sim-orchestrator.service.ts` | ~200 | ✅ Excellent | NONE | -| `billing-orchestrator.service.ts` | ~47 | ✅ Perfect | NONE | - ---- - -## HIGH Priority: Order Fulfillment Orchestrator - -### Current Problems - -1. **Inline Anonymous Functions in Distributed Transaction** (lines 172-557) - - 10 transaction steps defined as inline arrow functions - - Each step contains 20-80 lines of business logic - - Violates Single Responsibility Principle - - Difficult to unit test individual steps - -2. **File Size**: ~990 lines (should be < 300 for an orchestrator) - -3. **Mixed Concerns**: Helper methods embedded in orchestrator - - `extractConfigurations()` (60+ lines) - data extraction - - `extractContactIdentity()` (50+ lines) - data extraction - - `formatBirthdayToYYYYMMDD()` (30 lines) - date formatting - -4. **Duplicate Logic**: Step tracking pattern repeated in each step - -### Target Architecture - -``` -apps/bff/src/modules/orders/ -├── services/ -│ ├── order-fulfillment-orchestrator.service.ts (~200 lines) -│ ├── order-fulfillment-steps/ -│ │ ├── index.ts -│ │ ├── validation.step.ts -│ │ ├── sf-status-update.step.ts -│ │ ├── sim-fulfillment.step.ts -│ │ ├── sf-activated-update.step.ts -│ │ ├── whmcs-mapping.step.ts -│ │ ├── whmcs-create.step.ts -│ │ ├── whmcs-accept.step.ts -│ │ ├── sf-registration-complete.step.ts -│ │ └── opportunity-update.step.ts -│ └── mappers/ -│ └── order-configuration.mapper.ts -``` - -### Refactoring Steps - -#### Step 1: Create Step Interface and Base Class - -```typescript -// apps/bff/src/modules/orders/services/order-fulfillment-steps/fulfillment-step.interface.ts - -import type { OrderFulfillmentContext } from "../order-fulfillment-orchestrator.service.js"; -import type { TransactionStep } from "@bff/infra/database/services/distributed-transaction.service.js"; - -export interface FulfillmentStepConfig { - context: OrderFulfillmentContext; - logger: Logger; -} - -export interface FulfillmentStep { - readonly id: string; - readonly description: string; - readonly critical: boolean; - - /** - * Build the transaction step for the distributed transaction - */ - build(config: FulfillmentStepConfig): TransactionStep; - - /** - * Check if this step should be included based on context - */ - shouldInclude(context: OrderFulfillmentContext): boolean; -} -``` - -#### Step 2: Extract Each Step to Its Own Class - -**Example: SIM Fulfillment Step** - -```typescript -// apps/bff/src/modules/orders/services/order-fulfillment-steps/sim-fulfillment.step.ts - -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import type { FulfillmentStep, FulfillmentStepConfig } from "./fulfillment-step.interface.js"; -import type { OrderFulfillmentContext } from "../order-fulfillment-orchestrator.service.js"; -import { SimFulfillmentService } from "../sim-fulfillment.service.js"; -import { OrderConfigurationMapper } from "../mappers/order-configuration.mapper.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; - -@Injectable() -export class SimFulfillmentStep implements FulfillmentStep { - readonly id = "sim_fulfillment"; - readonly description = "SIM activation via Freebit (PA05-18 + PA02-01 + PA05-05)"; - readonly critical = true; // Set dynamically based on SIM type - - constructor( - private readonly simFulfillmentService: SimFulfillmentService, - private readonly configMapper: OrderConfigurationMapper, - @Inject(Logger) private readonly logger: Logger - ) {} - - shouldInclude(context: OrderFulfillmentContext): boolean { - return context.orderDetails?.orderType === "SIM"; - } - - build(config: FulfillmentStepConfig) { - const { context } = config; - - return { - id: this.id, - description: this.description, - execute: async () => this.execute(context), - rollback: async () => this.rollback(context), - critical: context.validation?.sfOrder?.SIM_Type__c === "Physical SIM", - }; - } - - private async execute(context: OrderFulfillmentContext) { - if (context.orderDetails?.orderType !== "SIM") { - return { activated: false, simType: "eSIM" as const }; - } - - const sfOrder = context.validation?.sfOrder; - const configurations = this.configMapper.extractConfigurations( - context.payload?.configurations, - sfOrder - ); - - const assignedPhysicalSimId = this.extractAssignedSimId(sfOrder); - const voiceMailEnabled = sfOrder?.SIM_Voice_Mail__c === true; - const callWaitingEnabled = sfOrder?.SIM_Call_Waiting__c === true; - const contactIdentity = this.configMapper.extractContactIdentity(sfOrder); - - this.logger.log("Starting SIM fulfillment (before WHMCS)", { - orderId: context.sfOrderId, - simType: sfOrder?.SIM_Type__c, - assignedPhysicalSimId, - voiceMailEnabled, - callWaitingEnabled, - hasContactIdentity: !!contactIdentity, - }); - - const result = await this.simFulfillmentService.fulfillSimOrder({ - orderDetails: context.orderDetails, - configurations, - assignedPhysicalSimId, - voiceMailEnabled, - callWaitingEnabled, - contactIdentity, - }); - - context.simFulfillmentResult = result; - return result; - } - - private async rollback(context: OrderFulfillmentContext) { - this.logger.warn("SIM fulfillment step needs rollback - manual intervention may be required", { - sfOrderId: context.sfOrderId, - simFulfillmentResult: context.simFulfillmentResult, - }); - } - - private extractAssignedSimId(sfOrder?: SalesforceOrderRecord | null): string | undefined { - return typeof sfOrder?.Assign_Physical_SIM__c === "string" - ? sfOrder.Assign_Physical_SIM__c - : undefined; - } -} -``` - -#### Step 3: Extract Configuration Mapper - -```typescript -// apps/bff/src/modules/orders/services/mappers/order-configuration.mapper.ts - -import { Injectable } from "@nestjs/common"; -import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers"; -import type { ContactIdentityData } from "../sim-fulfillment.service.js"; - -@Injectable() -export class OrderConfigurationMapper { - /** - * Extract and normalize configurations from payload and Salesforce order - */ - extractConfigurations( - rawConfigurations: unknown, - sfOrder?: SalesforceOrderRecord | null - ): Record { - const config: Record = {}; - - if (rawConfigurations && typeof rawConfigurations === "object") { - Object.assign(config, rawConfigurations as Record); - } - - if (sfOrder) { - this.fillFromSalesforceOrder(config, sfOrder); - } - - return config; - } - - /** - * Extract contact identity data from Salesforce order porting fields - */ - extractContactIdentity(sfOrder?: SalesforceOrderRecord | null): ContactIdentityData | undefined { - if (!sfOrder) return undefined; - - const firstnameKanji = sfOrder.Porting_FirstName__c; - const lastnameKanji = sfOrder.Porting_LastName__c; - const firstnameKana = sfOrder.Porting_FirstName_Katakana__c; - const lastnameKana = sfOrder.Porting_LastName_Katakana__c; - const genderRaw = sfOrder.Porting_Gender__c; - const birthdayRaw = sfOrder.Porting_DateOfBirth__c; - - if (!this.validateNameFields(firstnameKanji, lastnameKanji, firstnameKana, lastnameKana)) { - return undefined; - } - - const gender = this.validateGender(genderRaw); - if (!gender) return undefined; - - const birthday = this.formatBirthdayToYYYYMMDD(birthdayRaw); - if (!birthday) return undefined; - - return { - firstnameKanji: firstnameKanji!, - lastnameKanji: lastnameKanji!, - firstnameKana: firstnameKana!, - lastnameKana: lastnameKana!, - gender, - birthday, - }; - } - - /** - * Format birthday from various formats to YYYYMMDD - */ - formatBirthdayToYYYYMMDD(dateStr?: string | null): string | undefined { - if (!dateStr) return undefined; - - if (/^\d{8}$/.test(dateStr)) { - return dateStr; - } - - const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (isoMatch) { - return `${isoMatch[1]}${isoMatch[2]}${isoMatch[3]}`; - } - - try { - const date = new Date(dateStr); - if (!isNaN(date.getTime())) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}${month}${day}`; - } - } catch { - // Parsing failed - } - - return undefined; - } - - private fillFromSalesforceOrder( - config: Record, - sfOrder: SalesforceOrderRecord - ): void { - const fieldMappings: Array<[string, keyof SalesforceOrderRecord]> = [ - ["simType", "SIM_Type__c"], - ["eid", "EID__c"], - ["activationType", "Activation_Type__c"], - ["scheduledAt", "Activation_Scheduled_At__c"], - ["mnpPhone", "MNP_Phone_Number__c"], - ["mnpNumber", "MNP_Reservation_Number__c"], - ["mnpExpiry", "MNP_Expiry_Date__c"], - ["mvnoAccountNumber", "MVNO_Account_Number__c"], - ["portingFirstName", "Porting_FirstName__c"], - ["portingLastName", "Porting_LastName__c"], - ["portingFirstNameKatakana", "Porting_FirstName_Katakana__c"], - ["portingLastNameKatakana", "Porting_LastName_Katakana__c"], - ["portingGender", "Porting_Gender__c"], - ["portingDateOfBirth", "Porting_DateOfBirth__c"], - ]; - - for (const [configKey, sfField] of fieldMappings) { - if (!config[configKey] && sfOrder[sfField]) { - config[configKey] = sfOrder[sfField]; - } - } - - // Handle MNP flag specially - if (!config["isMnp"] && sfOrder.MNP_Application__c) { - config["isMnp"] = "true"; - } - } - - private validateNameFields( - firstnameKanji?: string | null, - lastnameKanji?: string | null, - firstnameKana?: string | null, - lastnameKana?: string | null - ): boolean { - return !!(firstnameKanji && lastnameKanji && firstnameKana && lastnameKana); - } - - private validateGender(genderRaw?: string | null): "M" | "F" | undefined { - return genderRaw === "M" || genderRaw === "F" ? genderRaw : undefined; - } -} -``` - -#### Step 4: Create Step Builder Service - -```typescript -// apps/bff/src/modules/orders/services/order-fulfillment-steps/fulfillment-step-builder.service.ts - -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import type { TransactionStep } from "@bff/infra/database/services/distributed-transaction.service.js"; -import type { OrderFulfillmentContext } from "../order-fulfillment-orchestrator.service.js"; - -// Import all steps -import { ValidationStep } from "./validation.step.js"; -import { SfStatusUpdateStep } from "./sf-status-update.step.js"; -import { SimFulfillmentStep } from "./sim-fulfillment.step.js"; -import { SfActivatedUpdateStep } from "./sf-activated-update.step.js"; -import { WhmcsMappingStep } from "./whmcs-mapping.step.js"; -import { WhmcsCreateStep } from "./whmcs-create.step.js"; -import { WhmcsAcceptStep } from "./whmcs-accept.step.js"; -import { SfRegistrationCompleteStep } from "./sf-registration-complete.step.js"; -import { OpportunityUpdateStep } from "./opportunity-update.step.js"; - -@Injectable() -export class FulfillmentStepBuilder { - private readonly allSteps: FulfillmentStep[]; - - constructor( - validationStep: ValidationStep, - sfStatusUpdateStep: SfStatusUpdateStep, - simFulfillmentStep: SimFulfillmentStep, - sfActivatedUpdateStep: SfActivatedUpdateStep, - whmcsMappingStep: WhmcsMappingStep, - whmcsCreateStep: WhmcsCreateStep, - whmcsAcceptStep: WhmcsAcceptStep, - sfRegistrationCompleteStep: SfRegistrationCompleteStep, - opportunityUpdateStep: OpportunityUpdateStep, - @Inject(Logger) private readonly logger: Logger - ) { - // Define step execution order - this.allSteps = [ - sfStatusUpdateStep, - simFulfillmentStep, - sfActivatedUpdateStep, - whmcsMappingStep, - whmcsCreateStep, - whmcsAcceptStep, - sfRegistrationCompleteStep, - opportunityUpdateStep, - ]; - } - - /** - * Build transaction steps based on context - */ - buildTransactionSteps(context: OrderFulfillmentContext): TransactionStep[] { - const config = { context, logger: this.logger }; - - return this.allSteps - .filter(step => step.shouldInclude(context)) - .map(step => step.build(config)); - } -} -``` - -#### Step 5: Refactor Orchestrator to Use Step Builder - -```typescript -// apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts (REFACTORED) - -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js"; -import { FulfillmentStepBuilder } from "./order-fulfillment-steps/fulfillment-step-builder.service.js"; -import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service.js"; -import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js"; -import { OrderEventsService } from "./order-events.service.js"; -import { OrdersCacheService } from "./orders-cache.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { FulfillmentException } from "@bff/core/exceptions/domain-exceptions.js"; - -// ... types remain the same ... - -@Injectable() -export class OrderFulfillmentOrchestrator { - constructor( - @Inject(Logger) private readonly logger: Logger, - private readonly stepBuilder: FulfillmentStepBuilder, - private readonly validator: OrderFulfillmentValidator, - private readonly errorService: OrderFulfillmentErrorService, - private readonly transactionService: DistributedTransactionService, - private readonly orderEvents: OrderEventsService, - private readonly ordersCache: OrdersCacheService - ) {} - - async executeFulfillment( - sfOrderId: string, - payload: Record, - idempotencyKey: string - ): Promise { - const context = this.initializeContext(sfOrderId, idempotencyKey, payload); - - this.logger.log("Starting transactional fulfillment orchestration", { - sfOrderId, - idempotencyKey, - }); - - try { - // Step 1: Validate - const validation = await this.validator.validateFulfillmentRequest(sfOrderId, idempotencyKey); - context.validation = validation; - - if (validation.isAlreadyProvisioned) { - return this.handleAlreadyProvisioned(context); - } - - // Step 2: Build and execute transaction steps - const steps = this.stepBuilder.buildTransactionSteps(context); - const result = await this.transactionService.executeDistributedTransaction(steps, { - description: `Order fulfillment for ${sfOrderId}`, - timeout: 300000, - continueOnNonCriticalFailure: true, - }); - - if (!result.success) { - throw new FulfillmentException(result.error || "Fulfillment transaction failed", { - sfOrderId, - idempotencyKey, - stepsExecuted: result.stepsExecuted, - stepsRolledBack: result.stepsRolledBack, - }); - } - - this.logger.log("Transactional fulfillment completed successfully", { - sfOrderId, - stepsExecuted: result.stepsExecuted, - duration: result.duration, - }); - - await this.invalidateCaches(context); - return context; - } catch (error) { - await this.handleFulfillmentError(context, error as Error); - throw error; - } - } - - private initializeContext( - sfOrderId: string, - idempotencyKey: string, - payload: Record - ): OrderFulfillmentContext { - return { - sfOrderId, - idempotencyKey, - validation: null, - payload, - steps: [], - }; - } - - private handleAlreadyProvisioned(context: OrderFulfillmentContext): OrderFulfillmentContext { - this.logger.log("Order already provisioned, skipping fulfillment", { - sfOrderId: context.sfOrderId, - }); - this.orderEvents.publish(context.sfOrderId, { - orderId: context.sfOrderId, - status: "Completed", - activationStatus: "Activated", - stage: "completed", - source: "fulfillment", - message: "Order already provisioned", - timestamp: new Date().toISOString(), - payload: { whmcsOrderId: context.validation?.whmcsOrderId }, - }); - return context; - } - - private async invalidateCaches(context: OrderFulfillmentContext): Promise { - const accountId = context.validation?.sfOrder?.AccountId; - await Promise.all([ - this.ordersCache.invalidateOrder(context.sfOrderId), - accountId ? this.ordersCache.invalidateAccountOrders(accountId) : Promise.resolve(), - ]).catch(error => { - this.logger.warn("Failed to invalidate caches", { error: extractErrorMessage(error) }); - }); - } - - private async handleFulfillmentError( - context: OrderFulfillmentContext, - error: Error - ): Promise { - await this.invalidateCaches(context); - await this.errorService.handleAndReport(context, error); - this.orderEvents.publishFailure(context.sfOrderId, error.message); - } - - getFulfillmentSummary(context: OrderFulfillmentContext) { - // ... same as before, no changes needed ... - } -} -``` - -#### Step 6: Update Module Registration - -```typescript -// apps/bff/src/modules/orders/orders.module.ts - -// Add new providers -import { FulfillmentStepBuilder } from "./services/order-fulfillment-steps/fulfillment-step-builder.service.js"; -import { OrderConfigurationMapper } from "./services/mappers/order-configuration.mapper.js"; -import { - SfStatusUpdateStep, - SimFulfillmentStep, - SfActivatedUpdateStep, - WhmcsMappingStep, - WhmcsCreateStep, - WhmcsAcceptStep, - SfRegistrationCompleteStep, - OpportunityUpdateStep, -} from "./services/order-fulfillment-steps/index.js"; - -@Module({ - providers: [ - // Existing services... - - // New step classes - FulfillmentStepBuilder, - OrderConfigurationMapper, - SfStatusUpdateStep, - SimFulfillmentStep, - SfActivatedUpdateStep, - WhmcsMappingStep, - WhmcsCreateStep, - WhmcsAcceptStep, - SfRegistrationCompleteStep, - OpportunityUpdateStep, - ], -}) -export class OrdersModule {} -``` - ---- - -## LOW Priority: Subscriptions Orchestrator - -### Current Issues - -1. Business logic in filter methods (lines 193-250): - - `getExpiringSoon()` - date filtering logic - - `getRecentActivity()` - date filtering logic - - `searchSubscriptions()` - search logic - -2. Cache helper methods could be in a separate service - -### Recommended Changes - -1. Extract date filtering to a utility: - - ```typescript - // @bff/core/utils/date-filter.util.ts - export function filterByDateRange( - items: T[], - dateExtractor: (item: T) => Date, - range: { start?: Date; end?: Date } - ): T[] { ... } - ``` - -2. Consider extracting `SubscriptionFilterService` if filtering becomes more complex - -**Status**: Not urgent - current implementation is acceptable - ---- - -## LOW Priority: Auth Orchestrator - -### Current Issues - -1. `getAccountStatus()` method (lines 241-296) contains complex conditional logic - - Could be extracted to `AccountStatusResolver` service - -### Recommended Changes - -```typescript -// apps/bff/src/modules/auth/application/account-status-resolver.service.ts - -@Injectable() -export class AccountStatusResolver { - async resolve(email: string): Promise { - // Move logic from getAccountStatus here - } -} -``` - -**Status**: Not urgent - method is well-contained and testable as-is - ---- - -## No Changes Needed - -### Order Orchestrator ✅ - -- Clean thin delegation pattern -- Appropriate size (~270 lines) -- Single private method is justified - -### SIM Orchestrator ✅ - -- Excellent thin delegation -- All methods delegate to specialized services -- `getSimInfo` has reasonable composition logic - -### Billing Orchestrator ✅ - -- Perfect thin facade pattern -- Pure delegation only -- Model example for others - ---- - -## Implementation Checklist - -### Phase 1: High Priority (Order Fulfillment Orchestrator) - -- [ ] Create `order-fulfillment-steps/` directory -- [ ] Create `fulfillment-step.interface.ts` -- [ ] Create `OrderConfigurationMapper` service -- [ ] Extract `SfStatusUpdateStep` -- [ ] Extract `SimFulfillmentStep` -- [ ] Extract `SfActivatedUpdateStep` -- [ ] Extract `WhmcsMappingStep` -- [ ] Extract `WhmcsCreateStep` -- [ ] Extract `WhmcsAcceptStep` -- [ ] Extract `SfRegistrationCompleteStep` -- [ ] Extract `OpportunityUpdateStep` -- [ ] Create `FulfillmentStepBuilder` service -- [ ] Refactor `OrderFulfillmentOrchestrator` to use step builder -- [ ] Update `OrdersModule` with new providers -- [ ] Add unit tests for each step class -- [ ] Add integration tests for step builder - -### Phase 2: Low Priority - -- [ ] Extract `SubscriptionFilterService` (if needed) -- [ ] Extract `AccountStatusResolver` (if needed) - ---- - -## Testing Strategy - -### Step Classes - -Each step class should have unit tests that verify: - -1. `shouldInclude()` returns correct boolean based on context -2. `execute()` performs expected operations -3. `rollback()` handles cleanup appropriately -4. Error cases are handled correctly - -### Step Builder - -Integration tests should verify: - -1. Correct steps are included for SIM orders -2. Correct steps are included for non-SIM orders -3. Step order is maintained -4. Context is properly passed between steps - -### Orchestrator - -E2E tests should verify: - -1. Complete fulfillment flow works -2. Partial failures trigger rollbacks -3. Already provisioned orders are handled -4. Error reporting works correctly - ---- - -## Benefits of This Refactoring - -1. **Testability**: Each step can be unit tested in isolation -2. **Maintainability**: Changes to one step don't affect others -3. **Readability**: Orchestrator becomes a thin coordinator -4. **Extensibility**: New steps can be added without modifying orchestrator -5. **Reusability**: Steps can potentially be reused in other workflows -6. **Debugging**: Easier to identify which step failed -7. **Code Review**: Smaller, focused PRs for each step - ---- - -## References - -- Google Engineering Practices: https://google.github.io/eng-practices/ -- Microsoft .NET Application Architecture: https://docs.microsoft.com/en-us/dotnet/architecture/ -- Clean Architecture by Robert C. Martin -- Domain-Driven Design by Eric Evans diff --git a/env/dev.env.sample b/env/dev.env.sample index 6f42f45d..93fe6325 100644 --- a/env/dev.env.sample +++ b/env/dev.env.sample @@ -106,3 +106,4 @@ SENDGRID_API_KEY= # DISABLE_CSRF=false # DISABLE_RATE_LIMIT=false # DISABLE_ACCOUNT_LOCKING=false +# SKIP_OTP=false # Skip OTP verification during login (dev only) diff --git a/packages/domain/opportunity/contract.ts b/packages/domain/opportunity/contract.ts index ede2d94e..253199db 100644 --- a/packages/domain/opportunity/contract.ts +++ b/packages/domain/opportunity/contract.ts @@ -585,7 +585,7 @@ export function getOrderTrackingSteps( return stages.map((s, index) => ({ label: s.label, - status: index < currentStep ? "completed" : (index === currentStep ? "current" : "upcoming"), + status: index < currentStep ? "completed" : index === currentStep ? "current" : "upcoming", })); } diff --git a/packages/domain/subscriptions/providers/whmcs/mapper.ts b/packages/domain/subscriptions/providers/whmcs/mapper.ts index c2177e51..fab3566e 100644 --- a/packages/domain/subscriptions/providers/whmcs/mapper.ts +++ b/packages/domain/subscriptions/providers/whmcs/mapper.ts @@ -195,9 +195,9 @@ export function transformWhmcsSubscriptionListResponse( const productContainer = parsed.products?.product; const products = Array.isArray(productContainer) ? productContainer - : (productContainer + : productContainer ? [productContainer] - : []); + : []; const subscriptions: Subscription[] = []; for (const product of products) { @@ -213,9 +213,9 @@ export function transformWhmcsSubscriptionListResponse( const totalResults = typeof totalResultsRaw === "number" ? totalResultsRaw - : (typeof totalResultsRaw === "string" + : typeof totalResultsRaw === "string" ? Number.parseInt(totalResultsRaw, 10) - : subscriptions.length); + : subscriptions.length; if (status) { const normalizedStatus = subscriptionStatusSchema.parse(status);