Compare commits

...

2 Commits

22 changed files with 1110 additions and 449 deletions

View File

@ -1,2 +1,2 @@
import{a as t}from"./index-fG9vuU_E.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BPwaxA-i.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion}; import{a as t}from"./index-BuDjHQd8.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BMozKGOM.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-CFqAjzFU.js.map //# sourceMappingURL=conclusionApi-XzPsIGYn.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-CFqAjzFU.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"} {"version":3,"file":"conclusionApi-XzPsIGYn.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
import{g as s}from"./index-fG9vuU_E.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BPwaxA-i.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest}; import{g as s}from"./index-BuDjHQd8.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BMozKGOM.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest};
//# sourceMappingURL=requestNavigation-KN4bh371.js.map //# sourceMappingURL=requestNavigation-Dsrv0hfs.js.map

View File

@ -1 +1 @@
{"version":3,"file":"requestNavigation-KN4bh371.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"} {"version":3,"file":"requestNavigation-Dsrv0hfs.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -52,15 +52,15 @@
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-fG9vuU_E.js"></script> <script type="module" crossorigin src="/assets/index-BuDjHQd8.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js"> <link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DA0cB_hD.js"> <link rel="modulepreload" crossorigin href="/assets/radix-vendor-DA0cB_hD.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BPwaxA-i.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-BMozKGOM.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js"> <link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js"> <link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-CRr9x_Jp.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-CRr9x_Jp.js">
<link rel="stylesheet" crossorigin href="/assets/index-DAM_E-zB.css"> <link rel="stylesheet" crossorigin href="/assets/index-DImBgs5K.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -0,0 +1,193 @@
# Tanflow SSO User Data Mapping
This document outlines all user information available from Tanflow IAM Suite and how it maps to our User model for user creation.
## Tanflow Userinfo Endpoint Response
Tanflow uses **OpenID Connect (OIDC) standard claims** via the `/protocol/openid-connect/userinfo` endpoint. The following fields are available:
### Standard OIDC Claims (Available from Tanflow)
| Tanflow Field | OIDC Standard Claim | Type | Description | Currently Extracted |
|--------------|---------------------|------|--------------|-------------------|
| `sub` | `sub` | string | **REQUIRED** - Subject identifier (unique user ID) | ✅ Yes (as `oktaSub`) |
| `email` | `email` | string | Email address | ✅ Yes |
| `email_verified` | `email_verified` | boolean | Email verification status | ❌ No |
| `preferred_username` | `preferred_username` | string | Preferred username (fallback for email) | ✅ Yes (fallback) |
| `name` | `name` | string | Full display name | ✅ Yes (as `displayName`) |
| `given_name` | `given_name` | string | First name | ✅ Yes (as `firstName`) |
| `family_name` | `family_name` | string | Last name | ✅ Yes (as `lastName`) |
| `phone_number` | `phone_number` | string | Phone number | ✅ Yes (as `phone`) |
| `phone_number_verified` | `phone_number_verified` | boolean | Phone verification status | ❌ No |
| `address` | `address` | object | Address object (structured) | ❌ No |
| `locale` | `locale` | string | User locale (e.g., "en-US") | ❌ No |
| `picture` | `picture` | string | Profile picture URL | ❌ No |
| `website` | `website` | string | Website URL | ❌ No |
| `profile` | `profile` | string | Profile page URL | ❌ No |
| `birthdate` | `birthdate` | string | Date of birth | ❌ No |
| `gender` | `gender` | string | Gender | ❌ No |
| `zoneinfo` | `zoneinfo` | string | Timezone (e.g., "America/New_York") | ❌ No |
| `updated_at` | `updated_at` | number | Last update timestamp | ❌ No |
### Custom Tanflow Claims (May be available)
These are **custom claims** that Tanflow may include based on their configuration:
| Tanflow Field | Type | Description | Currently Extracted |
|--------------|------|-------------|-------------------|
| `employeeId` | string | Employee ID from HR system | ✅ Yes |
| `employee_id` | string | Alternative employee ID field | ✅ Yes (fallback) |
| `department` | string | Department/Division | ✅ Yes |
| `designation` | string | Job designation/position | ✅ Yes |
| `title` | string | Job title | ✅ Yes (as `designation` fallback) |
| `organization` | string | Organization name | ❌ No |
| `division` | string | Division name | ❌ No |
| `location` | string | Office location | ❌ No |
| `manager` | string | Manager name/email | ❌ No |
| `manager_id` | string | Manager employee ID | ❌ No |
| `cost_center` | string | Cost center code | ❌ No |
| `employee_type` | string | Employee type (Full-time, Contract, etc.) | ❌ No |
| `hire_date` | string | Date of hire | ❌ No |
| `office_location` | string | Office location | ❌ No |
| `country` | string | Country code | ❌ No |
| `city` | string | City name | ❌ No |
| `state` | string | State/Province | ❌ No |
| `postal_code` | string | Postal/ZIP code | ❌ No |
| `groups` | array | Group memberships | ❌ No |
| `roles` | array | User roles | ❌ No |
## Current Extraction Logic
**Location:** `Re_Backend/src/services/auth.service.ts``exchangeTanflowCodeForTokens()`
```typescript
const userData: SSOUserData = {
oktaSub: tanflowSub, // Reuse oktaSub field for Tanflow sub
email: tanflowUserInfo.email || tanflowUserInfo.preferred_username || '',
employeeId: tanflowUserInfo.employeeId || tanflowUserInfo.employee_id || undefined,
firstName: tanflowUserInfo.given_name || tanflowUserInfo.firstName || undefined,
lastName: tanflowUserInfo.family_name || tanflowUserInfo.lastName || undefined,
displayName: tanflowUserInfo.name || tanflowUserInfo.displayName || undefined,
department: tanflowUserInfo.department || undefined,
designation: tanflowUserInfo.title || tanflowUserInfo.designation || undefined,
phone: tanflowUserInfo.phone_number || tanflowUserInfo.phone || undefined,
};
```
## User Model Fields Mapping
**Location:** `Re_Backend/src/models/User.ts`
| User Model Field | Tanflow Source | Required | Notes |
|-----------------|----------------|----------|-------|
| `userId` | Auto-generated UUID | ✅ | Primary key |
| `oktaSub` | `sub` | ✅ | Unique identifier from Tanflow |
| `email` | `email` or `preferred_username` | ✅ | Primary identifier |
| `employeeId` | `employeeId` or `employee_id` | ❌ | Optional HR system ID |
| `firstName` | `given_name` or `firstName` | ❌ | Optional |
| `lastName` | `family_name` or `lastName` | ❌ | Optional |
| `displayName` | `name` or `displayName` | ❌ | Auto-generated if missing |
| `department` | `department` | ❌ | Optional |
| `designation` | `title` or `designation` | ❌ | Optional |
| `phone` | `phone_number` or `phone` | ❌ | Optional |
| `manager` | `manager` | ❌ | **NOT currently extracted** |
| `secondEmail` | N/A | ❌ | Not available from Tanflow |
| `jobTitle` | `title` | ❌ | **NOT currently extracted** |
| `employeeNumber` | N/A | ❌ | Not available from Tanflow |
| `postalAddress` | `address` (structured) | ❌ | **NOT currently extracted** |
| `mobilePhone` | N/A | ❌ | Not available from Tanflow |
| `adGroups` | `groups` | ❌ | **NOT currently extracted** |
| `location` | `address`, `city`, `state`, `country` | ❌ | **NOT currently extracted** |
| `role` | Default: 'USER' | ✅ | Default role assigned |
| `isActive` | Default: true | ✅ | Auto-set to true |
| `lastLogin` | Current timestamp | ✅ | Auto-set on login |
## Recommended Enhancements
### 1. Extract Additional Fields
Consider extracting these fields if available from Tanflow:
```typescript
// Enhanced extraction (to be implemented)
const userData: SSOUserData = {
// ... existing fields ...
// Additional fields
manager: tanflowUserInfo.manager || undefined,
jobTitle: tanflowUserInfo.title || tanflowUserInfo.designation || undefined,
postalAddress: tanflowUserInfo.address ? JSON.stringify(tanflowUserInfo.address) : undefined,
adGroups: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups : undefined,
// Location object
location: {
city: tanflowUserInfo.city || undefined,
state: tanflowUserInfo.state || undefined,
country: tanflowUserInfo.country || undefined,
office: tanflowUserInfo.office_location || undefined,
timezone: tanflowUserInfo.zoneinfo || undefined,
},
};
```
### 2. Log Available Fields
Add logging to see what Tanflow actually returns:
```typescript
logger.info('Tanflow userinfo response', {
availableFields: Object.keys(tanflowUserInfo),
hasEmail: !!tanflowUserInfo.email,
hasEmployeeId: !!(tanflowUserInfo.employeeId || tanflowUserInfo.employee_id),
hasDepartment: !!tanflowUserInfo.department,
hasManager: !!tanflowUserInfo.manager,
hasGroups: Array.isArray(tanflowUserInfo.groups),
groupsCount: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups.length : 0,
sampleData: {
sub: tanflowUserInfo.sub?.substring(0, 10) + '...',
email: tanflowUserInfo.email?.substring(0, 10) + '...',
name: tanflowUserInfo.name,
}
});
```
## User Creation Flow
1. **Token Exchange** → Get `access_token` from Tanflow
2. **Userinfo Call** → Call `/protocol/openid-connect/userinfo` with `access_token`
3. **Extract Data** → Map Tanflow fields to `SSOUserData` interface
4. **User Lookup** → Check if user exists by `email`
5. **Create/Update** → Create new user or update existing user
6. **Generate Tokens** → Generate JWT access/refresh tokens
## Testing Recommendations
1. **Test with Real Tanflow Account**
- Log actual userinfo response
- Document all available fields
- Verify field mappings
2. **Handle Missing Fields**
- Ensure graceful fallbacks
- Don't fail if optional fields are missing
- Log warnings for missing expected fields
3. **Validate Required Fields**
- `sub` (oktaSub) - REQUIRED
- `email` or `preferred_username` - REQUIRED
## Next Steps
1. ✅ **Current Implementation** - Basic OIDC claims extraction
2. 🔄 **Enhancement** - Extract additional custom claims (manager, groups, location)
3. 🔄 **Logging** - Add detailed logging of Tanflow response
4. 🔄 **Testing** - Test with real Tanflow account to see actual fields
5. 🔄 **Documentation** - Update this doc with actual Tanflow response structure
## Notes
- Tanflow uses **Keycloak** under the hood (based on URL structure)
- Keycloak supports custom user attributes that may be available
- Some fields may require specific realm/client configuration in Tanflow
- Contact Tanflow support to confirm available custom claims

View File

@ -12,6 +12,10 @@ const ssoConfig: SSOConfig = {
oktaClientId: process.env.OKTA_CLIENT_ID || '', oktaClientId: process.env.OKTA_CLIENT_ID || '',
oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '', oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '',
oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API
// Tanflow configuration for token exchange
tanflowBaseUrl: process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE',
tanflowClientId: process.env.TANFLOW_CLIENT_ID || 'REFLOW',
tanflowClientSecret: process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox',
}; };
export { ssoConfig }; export { ssoConfig };

View File

@ -159,6 +159,128 @@ export class AuthController {
} }
} }
/**
* Exchange Tanflow authorization code for tokens
* POST /api/v1/auth/tanflow/token-exchange
*/
async exchangeTanflowToken(req: Request, res: Response): Promise<void> {
try {
logger.info('Tanflow token exchange request received', {
body: {
code: req.body?.code ? `${req.body.code.substring(0, 10)}...` : 'MISSING',
redirectUri: req.body?.redirectUri,
state: req.body?.state ? 'PRESENT' : 'MISSING',
},
});
const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Tanflow token exchange validation passed', { redirectUri });
const result = await this.authService.exchangeTanflowCodeForTokens(code, redirectUri);
// Log login activity
const requestMeta = getRequestMetadata(req);
await activityService.log({
requestId: SYSTEM_EVENT_REQUEST_ID,
type: 'login',
user: {
userId: result.user.userId,
name: result.user.displayName || result.user.email,
email: result.user.email
},
timestamp: new Date().toISOString(),
action: 'User Login',
details: `User logged in via Tanflow SSO from ${requestMeta.ipAddress || 'unknown IP'}`,
metadata: {
loginMethod: 'TANFLOW_SSO',
employeeId: result.user.employeeId,
department: result.user.department,
role: result.user.role
},
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
category: 'AUTHENTICATION',
severity: 'INFO'
});
// Set tokens in httpOnly cookies (production) or return in body (development)
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? ('none' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/',
};
res.cookie('accessToken', result.accessToken, cookieOptions);
res.cookie('refreshToken', result.refreshToken, cookieOptions);
// In production, don't return tokens in response body (security)
// In development, include tokens for cross-port setup
if (isProduction) {
ResponseHandler.success(res, {
user: result.user,
idToken: result.oktaIdToken, // Include id_token for Tanflow logout
}, 'Authentication successful');
} else {
ResponseHandler.success(res, {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
idToken: result.oktaIdToken,
}, 'Authentication successful');
}
} catch (error) {
logger.error('Tanflow token exchange failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Tanflow authentication failed', 400, errorMessage);
}
}
/**
* Refresh Tanflow access token
* POST /api/v1/auth/tanflow/refresh
*/
async refreshTanflowToken(req: Request, res: Response): Promise<void> {
try {
const refreshToken = req.body?.refreshToken;
if (!refreshToken) {
ResponseHandler.error(res, 'Refresh token is required', 400, 'Refresh token is required in request body');
return;
}
const newAccessToken = await this.authService.refreshTanflowToken(refreshToken);
// Set new access token in cookie
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? ('none' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000,
path: '/',
};
res.cookie('accessToken', newAccessToken, cookieOptions);
if (isProduction) {
ResponseHandler.success(res, {
message: 'Token refreshed successfully'
}, 'Token refreshed successfully');
} else {
ResponseHandler.success(res, {
accessToken: newAccessToken
}, 'Token refreshed successfully');
}
} catch (error) {
logger.error('Tanflow token refresh failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Token refresh failed', 401, errorMessage);
}
}
/** /**
* Logout user * Logout user
* POST /api/v1/auth/logout * POST /api/v1/auth/logout

View File

@ -20,6 +20,18 @@ router.post('/token-exchange',
asyncHandler(authController.exchangeToken.bind(authController)) asyncHandler(authController.exchangeToken.bind(authController))
); );
// Tanflow token exchange endpoint (no authentication required)
router.post('/tanflow/token-exchange',
validateBody(tokenExchangeSchema),
asyncHandler(authController.exchangeTanflowToken.bind(authController))
);
// Tanflow token refresh endpoint (no authentication required)
router.post('/tanflow/refresh',
validateBody(refreshTokenSchema),
asyncHandler(authController.refreshTanflowToken.bind(authController))
);
// SSO callback endpoint (no authentication required) // SSO callback endpoint (no authentication required)
router.post('/sso-callback', router.post('/sso-callback',
validateBody(ssoCallbackSchema), validateBody(ssoCallbackSchema),

View File

@ -427,7 +427,8 @@ export class ApprovalService {
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
// Check if this is Department Lead approval in a claim management workflow, and next level is Activity Creation (auto-step) // Check if this is Department Lead approval in a claim management workflow
// Activity Creation is now an activity log only, not an approval step
const workflowType = (wf as any)?.workflowType; const workflowType = (wf as any)?.workflowType;
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT'; const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
@ -435,50 +436,34 @@ export class ApprovalService {
const currentLevelName = (level.levelName || '').toLowerCase(); const currentLevelName = (level.levelName || '').toLowerCase();
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3; const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
// Check if next level is Activity Creation (by levelName or approverEmail, not hardcoded step number) // Check if current level is Requestor Claim Approval (Step 5, was Step 6)
const nextLevelName = (nextLevel?.levelName || '').toLowerCase(); const currentLevelNameForStep5 = (level.levelName || '').toLowerCase();
const nextLevelEmail = (nextLevel?.approverEmail || '').toLowerCase(); const isRequestorClaimApproval = currentLevelNameForStep5.includes('requestor') &&
const isActivityCreationNext = nextLevelName.includes('activity creation') || (currentLevelNameForStep5.includes('claim') || currentLevelNameForStep5.includes('approval')) ||
(nextLevelEmail === 'system@royalenfield.com' && nextLevelNumber > level.levelNumber); level.levelNumber === 5;
// Check if current level is Requestor Claim Approval (Step 6) and next is E-Invoice Generation (Step 7) if (isClaimManagement && isDeptLeadApproval) {
const currentLevelNameForStep6 = (level.levelName || '').toLowerCase(); // Activity Creation is now an activity log only - process it automatically
const isRequestorClaimApproval = currentLevelNameForStep6.includes('requestor') && logger.info(`[Approval] Department Lead approved for claim management workflow. Processing Activity Creation as activity log.`);
(currentLevelNameForStep6.includes('claim') || currentLevelNameForStep6.includes('approval')) ||
level.levelNumber === 6;
const nextLevelNameForStep7 = (nextLevel?.levelName || '').toLowerCase();
const nextLevelEmailForStep7 = (nextLevel?.approverEmail || '').toLowerCase();
const isEInvoiceGenerationNext = nextLevelNameForStep7.includes('e-invoice') ||
nextLevelNameForStep7.includes('invoice generation') ||
(nextLevelEmailForStep7 === 'system@royalenfield.com' && nextLevelNumber > level.levelNumber);
if (isClaimManagement && isDeptLeadApproval && isActivityCreationNext && nextLevel) {
// Activity Creation is an auto-step - process it automatically
logger.info(`[Approval] Department Lead approved for claim management workflow. Auto-processing Activity Creation (Level ${nextLevelNumber})`);
try { try {
const dealerClaimService = new DealerClaimService(); const dealerClaimService = new DealerClaimService();
await dealerClaimService.processActivityCreation(level.requestId); await dealerClaimService.processActivityCreation(level.requestId);
logger.info(`[Approval] Activity Creation auto-processing completed for request ${level.requestId}`); logger.info(`[Approval] Activity Creation activity logged for request ${level.requestId}`);
} catch (step4Error) { } catch (activityError) {
logger.error(`[Approval] Error auto-processing Activity Creation for request ${level.requestId}:`, step4Error); logger.error(`[Approval] Error processing Activity Creation activity for request ${level.requestId}:`, activityError);
// Don't fail the Department Lead approval if Activity Creation processing fails - log and continue // Don't fail the Department Lead approval if Activity Creation logging fails - log and continue
} }
} else if (isClaimManagement && isRequestorClaimApproval && isEInvoiceGenerationNext && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') { } else if (isClaimManagement && isRequestorClaimApproval) {
// E-Invoice Generation is an auto-step - activate it but don't process invoice generation // E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook
// Invoice generation will be handled by DMS webhook when invoice is created logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice generation will be logged as activity when DMS webhook is received.`);
logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice Generation (Level ${nextLevelNumber}) activated. Waiting for DMS webhook to generate invoice.`);
// E-Invoice Generation will remain in IN_PROGRESS until webhook creates invoice and auto-approves it
// Continue with normal flow to activate E-Invoice Generation
} }
if (wf && nextLevel) { if (wf && nextLevel) {
// Normal flow - notify next approver (skip for auto-steps) // Normal flow - notify next approver (skip for auto-steps)
// Check if it's an auto-step by checking approverEmail or levelName // Check if it's an auto-step by checking approverEmail or levelName
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are no longer approval steps
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com' const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'
|| (nextLevel as any).approverName === 'System Auto-Process' || (nextLevel as any).approverName === 'System Auto-Process';
|| (nextLevel as any).levelName === 'Activity Creation'
|| (nextLevel as any).levelName === 'E-Invoice Generation';
// Log approval activity // Log approval activity
activityService.log({ activityService.log({
@ -494,7 +479,8 @@ export class ApprovalService {
// Log assignment activity for next level (when it becomes active) // Log assignment activity for next level (when it becomes active)
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps // IMPORTANT: Skip notifications and assignment logging for system/auto-steps
// System steps are: Activity Creation (Step 4), E-Invoice Generation (Step 7), and any step with system@royalenfield.com // System steps are any step with system@royalenfield.com
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps
// These steps are processed automatically and should NOT trigger notifications // These steps are processed automatically and should NOT trigger notifications
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') { if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
// Additional checks: ensure approverEmail and approverName are not system-related // Additional checks: ensure approverEmail and approverName are not system-related

View File

@ -283,7 +283,13 @@ export class AuthService {
if (userData.department) userUpdateData.department = userData.department; if (userData.department) userUpdateData.department = userData.department;
if (userData.designation) userUpdateData.designation = userData.designation; if (userData.designation) userUpdateData.designation = userData.designation;
if (userData.phone) userUpdateData.phone = userData.phone; if (userData.phone) userUpdateData.phone = userData.phone;
if (userData.manager) userUpdateData.manager = userData.manager; // Manager name from Okta if (userData.manager) userUpdateData.manager = userData.manager; // Manager name from SSO
if (userData.jobTitle) userUpdateData.jobTitle = userData.jobTitle; // Job title from SSO
if (userData.postalAddress) userUpdateData.postalAddress = userData.postalAddress; // Address from SSO
if (userData.mobilePhone) userUpdateData.mobilePhone = userData.mobilePhone; // Mobile phone from SSO
if (userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0) {
userUpdateData.adGroups = userData.adGroups; // Group memberships from SSO
}
// Check if user exists by email (primary identifier) // Check if user exists by email (primary identifier)
let user = await User.findOne({ let user = await User.findOne({
@ -313,7 +319,11 @@ export class AuthService {
department: userData.department || null, department: userData.department || null,
designation: userData.designation || null, designation: userData.designation || null,
phone: userData.phone || null, phone: userData.phone || null,
manager: userData.manager || null, // Manager name from Okta manager: userData.manager || null, // Manager name from SSO
jobTitle: userData.jobTitle || null, // Job title from SSO
postalAddress: userData.postalAddress || null, // Address from SSO
mobilePhone: userData.mobilePhone || null, // Mobile phone from SSO
adGroups: userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0 ? userData.adGroups : null, // Groups from SSO
isActive: true, isActive: true,
role: 'USER', role: 'USER',
lastLogin: new Date() lastLogin: new Date()
@ -786,4 +796,247 @@ export class AuthService {
throw new Error(`Okta authentication failed: ${error.message || 'Unknown error'}`); throw new Error(`Okta authentication failed: ${error.message || 'Unknown error'}`);
} }
} }
/**
* Exchange Tanflow authorization code for tokens
* Similar to Okta flow but uses Tanflow IAM endpoints
*/
async exchangeTanflowCodeForTokens(code: string, redirectUri: string): Promise<LoginResponse> {
try {
// Validate configuration
if (!ssoConfig.tanflowClientId || ssoConfig.tanflowClientId.trim() === '') {
throw new Error('TANFLOW_CLIENT_ID is not configured. Please set it in your .env file.');
}
if (!ssoConfig.tanflowClientSecret || ssoConfig.tanflowClientSecret.trim() === '') {
throw new Error('TANFLOW_CLIENT_SECRET is not configured. Please set it in your .env file.');
}
if (!code || code.trim() === '') {
throw new Error('Authorization code is required');
}
if (!redirectUri || redirectUri.trim() === '') {
throw new Error('Redirect URI is required');
}
logger.info('Exchanging code with Tanflow', {
redirectUri,
codePrefix: code.substring(0, 10) + '...',
tanflowBaseUrl: ssoConfig.tanflowBaseUrl,
clientId: ssoConfig.tanflowClientId,
hasClientSecret: !!ssoConfig.tanflowClientSecret,
});
const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`;
// Exchange authorization code for tokens
const tokenResponse = await axios.post(
tokenEndpoint,
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: ssoConfig.tanflowClientId!,
client_secret: ssoConfig.tanflowClientSecret!,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
responseType: 'json',
validateStatus: (status) => status < 500,
}
);
// Check for error response from Tanflow
if (tokenResponse.status !== 200) {
logger.error('Tanflow token exchange failed', {
status: tokenResponse.status,
statusText: tokenResponse.statusText,
data: tokenResponse.data,
});
const errorData = tokenResponse.data || {};
const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Tanflow';
throw new Error(`Tanflow token exchange failed (${tokenResponse.status}): ${errorMessage}`);
}
if (!tokenResponse.data || typeof tokenResponse.data !== 'object') {
logger.error('Invalid response from Tanflow', {
dataType: typeof tokenResponse.data,
isArray: Array.isArray(tokenResponse.data),
data: tokenResponse.data,
});
throw new Error('Invalid response format from Tanflow');
}
const { access_token, refresh_token, id_token } = tokenResponse.data;
if (!access_token) {
logger.error('Missing access_token in Tanflow response', {
responseKeys: Object.keys(tokenResponse.data || {}),
hasRefreshToken: !!refresh_token,
hasIdToken: !!id_token,
});
throw new Error('Failed to obtain access token from Tanflow - access_token missing in response');
}
logger.info('Successfully obtained tokens from Tanflow', {
hasAccessToken: !!access_token,
hasRefreshToken: !!refresh_token,
hasIdToken: !!id_token,
});
// Get user info from Tanflow userinfo endpoint
const userInfoEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/userinfo`;
const userInfoResponse = await axios.get(userInfoEndpoint, {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
const tanflowUserInfo = userInfoResponse.data;
const tanflowSub = tanflowUserInfo.sub || '';
if (!tanflowSub) {
throw new Error('Tanflow sub (subject identifier) is required but not found in response');
}
// Log available fields from Tanflow for debugging and planning
logger.info('Tanflow userinfo response received', {
availableFields: Object.keys(tanflowUserInfo),
hasEmail: !!tanflowUserInfo.email,
hasPreferredUsername: !!tanflowUserInfo.preferred_username,
hasEmployeeId: !!(tanflowUserInfo.employeeId || tanflowUserInfo.employee_id),
hasDepartment: !!tanflowUserInfo.department,
hasDesignation: !!(tanflowUserInfo.title || tanflowUserInfo.designation),
hasManager: !!tanflowUserInfo.manager,
hasGroups: Array.isArray(tanflowUserInfo.groups),
groupsCount: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups.length : 0,
hasLocation: !!(tanflowUserInfo.city || tanflowUserInfo.state || tanflowUserInfo.country),
hasAddress: !!tanflowUserInfo.address,
sampleData: {
sub: tanflowUserInfo.sub?.substring(0, 10) + '...',
email: tanflowUserInfo.email?.substring(0, 10) + '...',
name: tanflowUserInfo.name,
given_name: tanflowUserInfo.given_name,
family_name: tanflowUserInfo.family_name,
}
});
// Extract user data from Tanflow userinfo
// Tanflow uses standard OIDC claims, similar to Okta
// Also supports custom claims based on Tanflow configuration
const userData: SSOUserData = {
oktaSub: tanflowSub, // Reuse oktaSub field for Tanflow sub
email: tanflowUserInfo.email || tanflowUserInfo.preferred_username || '',
employeeId: tanflowUserInfo.employeeId || tanflowUserInfo.employee_id || undefined,
firstName: tanflowUserInfo.given_name || tanflowUserInfo.firstName || undefined,
lastName: tanflowUserInfo.family_name || tanflowUserInfo.lastName || undefined,
displayName: tanflowUserInfo.name || tanflowUserInfo.displayName || undefined,
department: tanflowUserInfo.department || undefined,
designation: tanflowUserInfo.title || tanflowUserInfo.designation || undefined,
phone: tanflowUserInfo.phone_number || tanflowUserInfo.phone || undefined,
// Additional fields that may be available from Tanflow (custom claims)
manager: tanflowUserInfo.manager || undefined,
jobTitle: tanflowUserInfo.title || tanflowUserInfo.designation || undefined,
postalAddress: tanflowUserInfo.address ? (typeof tanflowUserInfo.address === 'string' ? tanflowUserInfo.address : JSON.stringify(tanflowUserInfo.address)) : undefined,
mobilePhone: tanflowUserInfo.mobile_phone || tanflowUserInfo.mobilePhone || undefined,
adGroups: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups : undefined,
};
// Validate required fields
if (!userData.oktaSub || !userData.email) {
throw new Error('Email and Tanflow sub are required');
}
logger.info('Extracted user data from Tanflow', {
tanflowSub: userData.oktaSub,
email: userData.email,
employeeId: userData.employeeId || 'not provided',
hasDepartment: !!userData.department,
hasDesignation: !!userData.designation,
hasManager: !!userData.manager,
hasJobTitle: !!userData.jobTitle,
hasPostalAddress: !!userData.postalAddress,
hasMobilePhone: !!userData.mobilePhone,
hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0,
adGroupsCount: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.length : 0,
});
// Handle SSO callback to create/update user and generate our tokens
const result = await this.handleSSOCallback(userData);
// Return our JWT tokens along with Tanflow tokens
return {
...result,
// Store Tanflow tokens separately if needed (especially id_token for logout)
oktaRefreshToken: refresh_token, // Reuse oktaRefreshToken field
oktaAccessToken: access_token, // Reuse oktaAccessToken field
oktaIdToken: id_token, // Reuse oktaIdToken field for Tanflow logout
};
} catch (error: any) {
logAuthEvent('auth_failure', undefined, {
action: 'tanflow_token_exchange_failed',
errorMessage: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
tanflowError: error.response?.data?.error,
tanflowErrorDescription: error.response?.data?.error_description,
});
if (error.response?.data) {
const errorData = error.response.data;
if (typeof errorData === 'object' && !Array.isArray(errorData)) {
const errorMsg = errorData.error_description || errorData.error || error.message;
throw new Error(`Tanflow authentication failed: ${errorMsg}`);
} else {
logger.error('Unexpected error response format from Tanflow', {
dataType: typeof errorData,
isArray: Array.isArray(errorData),
});
throw new Error(`Tanflow authentication failed: Unexpected response format. Status: ${error.response.status}`);
}
}
throw new Error(`Tanflow authentication failed: ${error.message || 'Unknown error'}`);
}
}
/**
* Refresh Tanflow access token using refresh token
*/
async refreshTanflowToken(refreshToken: string): Promise<string> {
try {
if (!ssoConfig.tanflowClientId || !ssoConfig.tanflowClientSecret) {
throw new Error('Tanflow client credentials not configured');
}
const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`;
const response = await axios.post(
tokenEndpoint,
new URLSearchParams({
grant_type: 'refresh_token',
client_id: ssoConfig.tanflowClientId!,
client_secret: ssoConfig.tanflowClientSecret!,
refresh_token: refreshToken,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
}
);
if (response.status !== 200 || !response.data.access_token) {
throw new Error('Failed to refresh Tanflow token');
}
return response.data.access_token;
} catch (error: any) {
logger.error('Tanflow token refresh failed:', error);
throw new Error(`Tanflow token refresh failed: ${error.message || 'Unknown error'}`);
}
}
} }

View File

@ -64,7 +64,18 @@ export class DealerClaimService {
// Generate request number // Generate request number
const requestNumber = await generateRequestNumber(); const requestNumber = await generateRequestNumber();
// Validate initiator // Validate initiator - check if userId is a valid UUID first
const isValidUUID = (str: string): boolean => {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
if (!isValidUUID(userId)) {
// If userId is not a UUID (might be Okta ID), try to find by email or other means
// This shouldn't happen in normal flow, but handle gracefully
throw new Error(`Invalid initiator ID format. Expected UUID, got: ${userId}`);
}
const initiator = await User.findByPk(userId); const initiator = await User.findByPk(userId);
if (!initiator) { if (!initiator) {
throw new Error('Initiator not found'); throw new Error('Initiator not found');
@ -88,7 +99,7 @@ export class DealerClaimService {
description: claimData.requestDescription, description: claimData.requestDescription,
priority: Priority.STANDARD, priority: Priority.STANDARD,
status: WorkflowStatus.PENDING, // Submitted, not draft status: WorkflowStatus.PENDING, // Submitted, not draft
totalLevels: 8, // Fixed 8-step workflow for claim management totalLevels: 5, // Fixed 5-step workflow for claim management (Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only)
currentLevel: 1, // Step 1: Dealer Proposal Submission currentLevel: 1, // Step 1: Dealer Proposal Submission
totalTatHours: 0, // Will be calculated from approval levels totalTatHours: 0, // Will be calculated from approval levels
isDraft: false, // Not a draft - submitted and ready for workflow isDraft: false, // Not a draft - submitted and ready for workflow
@ -224,16 +235,40 @@ export class DealerClaimService {
logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`); logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`);
return workflowRequest; return workflowRequest;
} catch (error) { } catch (error: any) {
logger.error('[DealerClaimService] Error creating claim request:', error); // Log detailed error information for debugging
const errorDetails: any = {
message: error.message,
name: error.name,
};
// Sequelize validation errors
if (error.errors && Array.isArray(error.errors)) {
errorDetails.validationErrors = error.errors.map((e: any) => ({
field: e.path,
message: e.message,
value: e.value,
}));
}
// Sequelize database errors
if (error.parent) {
errorDetails.databaseError = {
message: error.parent.message,
code: error.parent.code,
detail: error.parent.detail,
};
}
logger.error('[DealerClaimService] Error creating claim request:', errorDetails);
throw error; throw error;
} }
} }
/** /**
* Create 8-step approval levels for claim management from approvers array * Create 5-step approval levels for claim management from approvers array
* Validates and creates approval levels based on user-provided approvers * Validates and creates approval levels based on user-provided approvers
* Similar to custom request flow - all approvers are manually assigned * Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are handled as activity logs only, not approval steps
*/ */
private async createClaimApprovalLevelsFromApprovers( private async createClaimApprovalLevelsFromApprovers(
requestId: string, requestId: string,
@ -246,6 +281,9 @@ export class DealerClaimService {
level: number; level: number;
tat?: number | string; tat?: number | string;
tatType?: 'hours' | 'days'; tatType?: 'hours' | 'days';
stepName?: string; // For additional approvers
isAdditional?: boolean; // Flag for additional approvers
originalStepLevel?: number; // Original step level for fixed steps
}> = [] }> = []
): Promise<void> { ): Promise<void> {
const initiator = await User.findByPk(initiatorId); const initiator = await User.findByPk(initiatorId);
@ -253,65 +291,134 @@ export class DealerClaimService {
throw new Error('Initiator not found'); throw new Error('Initiator not found');
} }
// Step definitions with default TAT // Step definitions with default TAT (only manual approval steps)
// Note: Activity Creation (was level 4), E-Invoice Generation (was level 7), and Credit Note Confirmation (was level 8)
// are now handled as activity logs only, not approval steps
const stepDefinitions = [ const stepDefinitions = [
{ level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false }, { level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false },
{ level: 2, name: 'Requestor Evaluation', defaultTat: 48, isAuto: false }, { level: 2, name: 'Requestor Evaluation', defaultTat: 48, isAuto: false },
{ level: 3, name: 'Department Lead Approval', defaultTat: 72, isAuto: false }, { level: 3, name: 'Department Lead Approval', defaultTat: 72, isAuto: false },
{ level: 4, name: 'Activity Creation', defaultTat: 1, isAuto: true }, { level: 4, name: 'Dealer Completion Documents', defaultTat: 120, isAuto: false },
{ level: 5, name: 'Dealer Completion Documents', defaultTat: 120, isAuto: false }, { level: 5, name: 'Requestor Claim Approval', defaultTat: 48, isAuto: false },
{ level: 6, name: 'Requestor Claim Approval', defaultTat: 48, isAuto: false },
{ level: 7, name: 'E-Invoice Generation', defaultTat: 1, isAuto: true },
{ level: 8, name: 'Credit Note Confirmation', defaultTat: 48, isAuto: true }, // System/Finance step handled by webhook
]; ];
// Process each step // Sort approvers by level to process in order
for (const stepDef of stepDefinitions) { const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
const approver = approvers.find((a) => a.level === stepDef.level);
// Track which original steps have been processed
const processedOriginalSteps = new Set<number>();
// Process approvers in order by their level
for (const approver of sortedApprovers) {
let approverId: string | null = null; let approverId: string | null = null;
let approverEmail = ''; let approverEmail = '';
let approverName = 'System'; let approverName = 'System';
let tatHours = stepDef.defaultTat; let tatHours = 48; // Default TAT
let levelName = '';
if (stepDef.isAuto) { let isSystemStep = false;
// System steps - handled by webhooks, no user validation needed let isFinalApprover = false;
// Step 8 is System/Finance, others are System
if (stepDef.level === 8) { // Find the step definition this approver belongs to
approverId = initiatorId; // Use initiator ID as placeholder (no actual user needed) let stepDef = null;
approverName = 'System/Finance';
approverEmail = 'finance@royalenfield.com'; // Check if this is a system step by email (for backwards compatibility)
} else { const isSystemEmail = approver.email === 'system@royalenfield.com' || approver.email === 'finance@royalenfield.com';
approverId = initiatorId; // System steps use initiator ID as placeholder
approverName = 'System Auto-Process'; if (approver.isAdditional) {
approverEmail = 'system@royalenfield.com'; // Additional approver - use stepName from frontend
levelName = approver.stepName || 'Additional Approver';
isSystemStep = false;
isFinalApprover = false;
} else {
// Fixed step - find by originalStepLevel first, then by matching level
const originalLevel = approver.originalStepLevel || approver.level;
stepDef = stepDefinitions.find(s => s.level === originalLevel);
if (!stepDef) {
// Try to find by current level if originalStepLevel not provided
stepDef = stepDefinitions.find(s => s.level === approver.level);
} }
tatHours = stepDef.defaultTat;
} else if (approver) { // System steps (Activity Creation, E-Invoice Generation, Credit Note Confirmation) are no longer approval steps
// User-provided approver // They are handled as activity logs only
// If approver has system email but no step definition found, skip creating approval level
if (!stepDef && isSystemEmail) {
logger.info(`[DealerClaimService] Skipping system step approver at level ${approver.level} - system steps are now activity logs only`);
continue; // Skip creating approval level for system steps
}
if (stepDef) {
levelName = stepDef.name;
isSystemStep = false; // No system steps in approval levels anymore
isFinalApprover = stepDef.level === 5; // Last step is now Requestor Claim Approval (level 5)
processedOriginalSteps.add(stepDef.level);
} else {
// Fallback - shouldn't happen but handle gracefully
levelName = `Step ${approver.level}`;
isSystemStep = false;
logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}, using fallback name`);
}
// Ensure levelName is never empty and truncate if too long (max 100 chars)
if (!levelName || levelName.trim() === '') {
levelName = approver.isAdditional
? `Additional Approver - Level ${approver.level}`
: `Step ${approver.level}`;
logger.warn(`[DealerClaimService] levelName was empty for approver at level ${approver.level}, using fallback: ${levelName}`);
}
// Truncate levelName to max 100 characters (database constraint)
if (levelName.length > 100) {
logger.warn(`[DealerClaimService] levelName too long (${levelName.length} chars) for level ${approver.level}, truncating to 100 chars`);
levelName = levelName.substring(0, 97) + '...';
}
}
// System steps are no longer created as approval levels - they are activity logs only
// This code path should not be reached anymore, but kept for safety
if (isSystemStep) {
logger.warn(`[DealerClaimService] System step detected but should not create approval level. Skipping.`);
continue; // Skip creating approval level for system steps
}
{
// User-provided approver (fixed or additional)
if (!approver.email) { if (!approver.email) {
throw new Error(`Approver email is required for Step ${stepDef.level}: ${stepDef.name}`); throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`);
} }
// Calculate TAT in hours // Calculate TAT in hours
if (approver.tat) { if (approver.tat) {
const tat = Number(approver.tat); const tat = Number(approver.tat);
if (isNaN(tat) || tat <= 0) { if (isNaN(tat) || tat <= 0) {
throw new Error(`Invalid TAT for Step ${stepDef.level}. TAT must be a positive number.`); throw new Error(`Invalid TAT for level ${approver.level}. TAT must be a positive number.`);
} }
tatHours = approver.tatType === 'days' ? tat * 24 : tat; tatHours = approver.tatType === 'days' ? tat * 24 : tat;
} else if (stepDef) {
tatHours = stepDef.defaultTat;
} }
// Ensure user exists in database (create from Okta if needed) // Ensure user exists in database (create from Okta if needed)
let user: User | null = null; let user: User | null = null;
if (approver.userId) { // Helper function to check if a string is a valid UUID
// User ID provided - use it const isValidUUID = (str: string): boolean => {
user = await User.findByPk(approver.userId); const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
// Try to find user by userId if it's a valid UUID
if (approver.userId && isValidUUID(approver.userId)) {
try {
user = await User.findByPk(approver.userId);
} catch (error: any) {
// If findByPk fails (e.g., invalid UUID format), log and continue to email lookup
logger.debug(`[DealerClaimService] Could not find user by userId ${approver.userId}, will try email lookup`);
}
} }
// If user not found by ID (or userId was not a valid UUID), try email
if (!user && approver.email) { if (!user && approver.email) {
// User not found by ID, try to find or create by email
user = await User.findOne({ where: { email: approver.email.toLowerCase() } }); user = await User.findOne({ where: { email: approver.email.toLowerCase() } });
if (!user) { if (!user) {
@ -320,6 +427,7 @@ export class DealerClaimService {
try { try {
user = await this.userService.ensureUserExists({ user = await this.userService.ensureUserExists({
email: approver.email.toLowerCase(), email: approver.email.toLowerCase(),
userId: approver.userId, // Pass Okta ID if provided (ensureUserExists will handle it)
}) as any; }) as any;
logger.info(`[DealerClaimService] Successfully synced user ${approver.email} from Okta`); logger.info(`[DealerClaimService] Successfully synced user ${approver.email} from Okta`);
} catch (oktaError: any) { } catch (oktaError: any) {
@ -330,45 +438,112 @@ export class DealerClaimService {
} }
if (!user) { if (!user) {
throw new Error(`Could not resolve user for Step ${stepDef.level}: ${approver.email}`); throw new Error(`Could not resolve user for level ${approver.level}: ${approver.email}`);
} }
approverId = user.userId; approverId = user.userId;
approverEmail = user.email; approverEmail = user.email;
approverName = approver.name || user.displayName || user.email || 'Approver'; approverName = approver.name || user.displayName || user.email || 'Approver';
} else {
// No approver provided for required step
throw new Error(`Approver is required for Step ${stepDef.level}: ${stepDef.name}`);
} }
// Ensure we have a valid approverId // Ensure we have a valid approverId
if (!approverId) { if (!approverId) {
logger.error(`[DealerClaimService] No approverId resolved for step ${stepDef.level}, using initiator as fallback`); logger.error(`[DealerClaimService] No approverId resolved for level ${approver.level}, using initiator as fallback`);
approverId = initiatorId; approverId = initiatorId;
approverEmail = approverEmail || initiator.email; approverEmail = approverEmail || initiator.email;
approverName = approverName || 'Unknown Approver'; approverName = approverName || 'Unknown Approver';
} }
// Create approval level // Ensure approverId is a valid UUID before creating
const now = new Date(); const isValidUUID = (str: string): boolean => {
const isStep1 = stepDef.level === 1; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
await ApprovalLevel.create({ if (!approverId || !isValidUUID(approverId)) {
requestId, logger.error(`[DealerClaimService] Invalid approverId for level ${approver.level}: ${approverId}`);
levelNumber: stepDef.level, throw new Error(`Invalid approver ID format for level ${approver.level}. Expected UUID.`);
levelName: stepDef.name, }
approverId: approverId,
approverEmail, // Create approval level using the approver's level (which may be shifted)
approverName, const now = new Date();
tatHours: tatHours, const isStep1 = approver.level === 1;
status: stepDef.level === 1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
isFinalApprover: stepDef.level === 8, try {
elapsedHours: 0, // Check for duplicate level_number for this request_id (unique constraint)
remainingHours: tatHours, const existingLevel = await ApprovalLevel.findOne({
tatPercentageUsed: 0, where: {
levelStartTime: isStep1 ? now : undefined, requestId,
tatStartTime: isStep1 ? now : undefined, levelNumber: approver.level
} as any); }
});
if (existingLevel) {
logger.error(`[DealerClaimService] Duplicate level number ${approver.level} already exists for request ${requestId}`);
throw new Error(`Level ${approver.level} already exists for this request. This may indicate a duplicate approver.`);
}
await ApprovalLevel.create({
requestId,
levelNumber: approver.level, // Use the approver's level (may be shifted)
levelName: levelName, // Already validated and truncated above
approverId: approverId,
approverEmail: approverEmail || '',
approverName: approverName || 'Unknown',
tatHours: tatHours || 0,
status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
isFinalApprover: isFinalApprover || false,
elapsedHours: 0,
remainingHours: tatHours || 0,
tatPercentageUsed: 0,
levelStartTime: isStep1 ? now : undefined,
tatStartTime: isStep1 ? now : undefined,
// Note: tatDays is NOT included - it's auto-calculated by the database
} as any);
} catch (createError: any) {
// Log detailed validation errors
const errorDetails: any = {
message: createError.message,
name: createError.name,
level: approver.level,
levelName: levelName?.substring(0, 50), // Truncate for logging
approverId,
approverEmail,
approverName: approverName?.substring(0, 50),
tatHours,
};
// Sequelize validation errors
if (createError.errors && Array.isArray(createError.errors)) {
errorDetails.validationErrors = createError.errors.map((e: any) => ({
field: e.path,
message: e.message,
value: e.value,
type: e.type,
}));
}
// Database constraint errors
if (createError.parent) {
errorDetails.databaseError = {
message: createError.parent.message,
code: createError.parent.code,
detail: createError.parent.detail,
constraint: createError.parent.constraint,
};
}
logger.error(`[DealerClaimService] Failed to create approval level for level ${approver.level}:`, errorDetails);
throw new Error(`Failed to create approval level ${approver.level} (${levelName}): ${createError.message}`);
}
}
// Validate that required fixed steps were processed
const requiredSteps = stepDefinitions.filter(s => !s.isAuto);
for (const requiredStep of requiredSteps) {
if (!processedOriginalSteps.has(requiredStep.level)) {
logger.warn(`[DealerClaimService] Required step ${requiredStep.level} (${requiredStep.name}) was not found in approvers array`);
}
} }
} }
@ -461,6 +636,18 @@ export class DealerClaimService {
continue; continue;
} }
// Helper function to check if a string is a valid UUID
const isValidUUID = (str: string): boolean => {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
// Only try to find user if approverId is a valid UUID
if (!isValidUUID(approverId)) {
logger.warn(`[DealerClaimService] Invalid UUID format for approverId: ${approverId}, skipping participant creation`);
continue;
}
const approverUser = await User.findByPk(approverId); const approverUser = await User.findByPk(approverId);
if (approverUser) { if (approverUser) {
participantsToAdd.push({ participantsToAdd.push({
@ -1127,8 +1314,24 @@ export class DealerClaimService {
throw new Error('Invalid claim request'); throw new Error('Invalid claim request');
} }
if (request.currentLevel !== 5) { // Find the "Dealer Completion Documents" step by levelName (handles step shifts due to additional approvers)
throw new Error('Completion documents can only be submitted at step 5'); const approvalLevels = await ApprovalLevel.findAll({
where: { requestId },
order: [['levelNumber', 'ASC']]
});
const dealerCompletionStep = approvalLevels.find((level: any) => {
const levelName = (level.levelName || '').toLowerCase();
return levelName.includes('dealer completion') || levelName.includes('completion documents');
});
if (!dealerCompletionStep) {
throw new Error('Dealer Completion Documents step not found');
}
// Check if current level matches the Dealer Completion Documents step (handles step shifts)
if (request.currentLevel !== dealerCompletionStep.levelNumber) {
throw new Error(`Completion documents can only be submitted at the Dealer Completion Documents step (currently at step ${request.currentLevel})`);
} }
// Save completion details // Save completion details
@ -1171,10 +1374,10 @@ export class DealerClaimService {
} }
}); });
// Fallback: try to find by levelNumber 5 (for backwards compatibility) // Fallback: try to find by levelNumber 4 (new position after removing system steps)
if (!dealerCompletionLevel) { if (!dealerCompletionLevel) {
dealerCompletionLevel = await ApprovalLevel.findOne({ dealerCompletionLevel = await ApprovalLevel.findOne({
where: { requestId, levelNumber: 5 } where: { requestId, levelNumber: 4 }
}); });
} }
@ -1557,37 +1760,54 @@ export class DealerClaimService {
logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`); logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`);
} }
// Check if Requestor Claim Approval (Step 6) is approved - if not, approve it first // Check if Requestor Claim Approval is approved - if not, approve it first
// Find dynamically by levelName, not hardcoded step number // Find dynamically by levelName (handles step shifts due to additional approvers)
let requestorClaimLevel = await ApprovalLevel.findOne({ const approvalLevels = await ApprovalLevel.findAll({
where: { where: { requestId },
requestId, order: [['levelNumber', 'ASC']]
levelName: 'Requestor Claim Approval' });
}
let requestorClaimLevel = approvalLevels.find((level: any) => {
const levelName = (level.levelName || '').toLowerCase();
return levelName.includes('requestor') &&
(levelName.includes('claim') || levelName.includes('approval'));
}); });
// Fallback: try to find by levelNumber 6 (for backwards compatibility) // Fallback: try to find by levelNumber 5 (new position after removing system steps)
// But only if no match found by name (handles edge cases)
if (!requestorClaimLevel) { if (!requestorClaimLevel) {
requestorClaimLevel = await ApprovalLevel.findOne({ requestorClaimLevel = approvalLevels.find((level: any) => level.levelNumber === 5);
where: { requestId, levelNumber: 6 } }
});
// Validate that we're at the Requestor Claim Approval step before allowing DMS push
if (requestorClaimLevel && request.currentLevel !== requestorClaimLevel.levelNumber) {
throw new Error(`Cannot push to DMS. Request is currently at step ${request.currentLevel}, but Requestor Claim Approval is at step ${requestorClaimLevel.levelNumber}. Please complete all previous steps first.`);
} }
if (requestorClaimLevel && requestorClaimLevel.status !== ApprovalStatus.APPROVED) { if (requestorClaimLevel && requestorClaimLevel.status !== ApprovalStatus.APPROVED) {
logger.info(`[DealerClaimService] Requestor Claim Approval not approved yet. Auto-approving for request ${requestId}`); logger.info(`[DealerClaimService] Requestor Claim Approval not approved yet. Auto-approving for request ${requestId}`);
// Auto-approve Requestor Claim Approval - E-Invoice Generation will be activated but invoice generation will happen via webhook // Auto-approve Requestor Claim Approval
await this.approvalService.approveLevel( await this.approvalService.approveLevel(
requestorClaimLevel.levelId, requestorClaimLevel.levelId,
{ action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. Waiting for DMS webhook to generate invoice.' }, { action: 'APPROVE', comments: 'Auto-approved when pushing to DMS. E-Invoice generation will be logged as activity.' },
'system', 'system',
{ ipAddress: null, userAgent: 'System Auto-Process' } { ipAddress: null, userAgent: 'System Auto-Process' }
); );
// Note: E-Invoice Generation will be handled by DMS webhook, not here logger.info(`[DealerClaimService] Requestor Claim Approval approved. E-Invoice generation will be logged as activity when DMS webhook is received.`);
logger.info(`[DealerClaimService] Requestor Claim Approval approved. E-Invoice Generation activated. Waiting for DMS webhook to generate invoice.`);
} else { } else {
// Requestor Claim Approval already approved - E-Invoice Generation should be active, waiting for webhook // Requestor Claim Approval already approved
logger.info(`[DealerClaimService] Requestor Claim Approval already approved. E-Invoice Generation should be active. Invoice generation will happen via DMS webhook.`); logger.info(`[DealerClaimService] Requestor Claim Approval already approved. E-Invoice generation will be logged as activity when DMS webhook is received.`);
} }
// Log E-Invoice generation as activity (no approval level needed)
await activityService.log({
requestId,
type: 'status_change',
user: { userId: 'system', name: 'System Auto-Process' },
timestamp: new Date().toISOString(),
action: 'E-Invoice Generation Initiated',
details: `E-Invoice generation initiated via DMS integration for request ${requestNumber}. Waiting for DMS webhook confirmation.`,
});
} catch (error) { } catch (error) {
logger.error('[DealerClaimService] Error updating e-invoice details:', error); logger.error('[DealerClaimService] Error updating e-invoice details:', error);
throw error; throw error;
@ -1595,13 +1815,12 @@ export class DealerClaimService {
} }
/** /**
* Process Step 7: E-Invoice Generation (Deprecated - now handled by DMS webhook) * Log E-Invoice Generation as activity (no longer an approval step)
* This method is kept for backward compatibility but invoice generation is now triggered only via DMS webhook * This method logs the e-invoice generation activity when invoice is generated via DMS webhook
* @deprecated Use DMS webhook to trigger invoice generation instead
*/ */
async processEInvoiceGeneration(requestId: string): Promise<void> { async logEInvoiceGenerationActivity(requestId: string, invoiceNumber?: string): Promise<void> {
try { try {
logger.info(`[DealerClaimService] Processing Step 7: E-Invoice Generation for request ${requestId}`); logger.info(`[DealerClaimService] Logging E-Invoice Generation activity for request ${requestId}`);
const request = await WorkflowRequest.findByPk(requestId); const request = await WorkflowRequest.findByPk(requestId);
if (!request) { if (!request) {
@ -1610,59 +1829,28 @@ export class DealerClaimService {
const workflowType = (request as any).workflowType; const workflowType = (request as any).workflowType;
if (workflowType !== 'CLAIM_MANAGEMENT') { if (workflowType !== 'CLAIM_MANAGEMENT') {
logger.warn(`[DealerClaimService] Skipping Step 7 auto-processing - not a claim management workflow (type: ${workflowType})`); logger.warn(`[DealerClaimService] Skipping E-Invoice activity logging - not a claim management workflow (type: ${workflowType})`);
return; return;
} }
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN';
if (!claimDetails) { const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
throw new Error(`Claim details not found for request ${requestId}`); const finalInvoiceNumber = invoiceNumber || claimInvoice?.invoiceNumber || 'N/A';
}
// Get Step 7 approval level // Log E-Invoice Generation as activity
// Get E-Invoice Generation approval level dynamically (by levelName, not hardcoded step number) await activityService.log({
let eInvoiceLevel = await ApprovalLevel.findOne({ requestId,
where: { type: 'status_change',
requestId, user: { userId: 'system', name: 'System Auto-Process' },
levelName: 'E-Invoice Generation' timestamp: new Date().toISOString(),
} action: 'E-Invoice Generated',
details: `E-Invoice generated via DMS. Invoice Number: ${finalInvoiceNumber}. Request: ${requestNumber}`,
}); });
// Fallback: try to find by levelNumber 7 (for backwards compatibility) logger.info(`[DealerClaimService] E-Invoice Generation activity logged for request ${requestId} (Invoice: ${finalInvoiceNumber})`);
if (!eInvoiceLevel) {
eInvoiceLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelNumber: 7
}
});
}
if (!eInvoiceLevel) {
throw new Error(`E-Invoice Generation approval level not found for request ${requestId}`);
}
// If e-invoice already generated, just auto-approve E-Invoice Generation
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
if (claimInvoice?.invoiceNumber) {
logger.info(`[DealerClaimService] E-Invoice already generated (${claimInvoice.invoiceNumber}). Auto-approving E-Invoice Generation for request ${requestId}`);
// Auto-approve E-Invoice Generation
await this.approvalService.approveLevel(
eInvoiceLevel.levelId,
{ action: 'APPROVE', comments: 'E-Invoice generated via DMS. E-Invoice Generation auto-approved.' },
'system',
{ ipAddress: null, userAgent: 'System Auto-Process' }
);
logger.info(`[DealerClaimService] Successfully auto-processed and approved E-Invoice Generation (Level ${eInvoiceLevel.levelNumber}) for request ${requestId}`);
} else {
logger.warn(`[DealerClaimService] E-Invoice not generated yet for request ${requestId}. E-Invoice Generation will be processed when e-invoice is generated.`);
}
} catch (error) { } catch (error) {
logger.error(`[DealerClaimService] Error processing Step 7 (E-Invoice Generation) for request ${requestId}:`, error); logger.error(`[DealerClaimService] Error logging E-Invoice Generation activity for request ${requestId}:`, error);
throw error; // Don't throw - activity logging is not critical
} }
} }
@ -1690,10 +1878,6 @@ export class DealerClaimService {
const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } });
const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } }); const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } });
if (!claimInvoice?.invoiceNumber) {
throw new Error('E-Invoice must be generated before creating credit note');
}
const request = await WorkflowRequest.findByPk(requestId); const request = await WorkflowRequest.findByPk(requestId);
const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN';
@ -1704,41 +1888,58 @@ export class DealerClaimService {
|| completionDetails?.totalClosedExpenses || completionDetails?.totalClosedExpenses
|| 0; || 0;
const creditNoteResult = await dmsIntegrationService.generateCreditNote({ // Only generate via DMS if invoice exists, otherwise allow manual entry
requestNumber, if (claimInvoice?.invoiceNumber) {
eInvoiceNumber: claimInvoice.invoiceNumber, const creditNoteResult = await dmsIntegrationService.generateCreditNote({
dealerCode: claimDetails.dealerCode, requestNumber,
dealerName: claimDetails.dealerName, eInvoiceNumber: claimInvoice.invoiceNumber,
amount: creditNoteAmount, dealerCode: claimDetails.dealerCode,
reason: creditNoteData?.reason || 'Claim settlement', dealerName: claimDetails.dealerName,
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`, amount: creditNoteAmount,
}); reason: creditNoteData?.reason || 'Claim settlement',
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
});
if (!creditNoteResult.success) { if (!creditNoteResult.success) {
throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`); throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`);
}
await ClaimCreditNote.upsert({
requestId,
invoiceId: claimInvoice.invoiceId,
creditNoteNumber: creditNoteResult.creditNoteNumber,
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
creditNoteAmount: creditNoteResult.creditNoteAmount,
status: 'GENERATED',
confirmedAt: new Date(),
reason: creditNoteData?.reason || 'Claim settlement',
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
});
logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, {
creditNoteNumber: creditNoteResult.creditNoteNumber,
creditNoteAmount: creditNoteResult.creditNoteAmount
});
} else {
// No invoice exists - create credit note manually without invoice link
await ClaimCreditNote.upsert({
requestId,
invoiceId: undefined, // No invoice linked
creditNoteNumber: undefined, // Will be set manually later
creditNoteDate: creditNoteData?.creditNoteDate || new Date(),
creditNoteAmount: creditNoteAmount,
status: 'PENDING',
reason: creditNoteData?.reason || 'Claim settlement',
description: creditNoteData?.description || `Credit note for claim request ${requestNumber} (no invoice)`,
});
logger.info(`[DealerClaimService] Credit note created without invoice for request: ${requestId}`);
} }
await ClaimCreditNote.upsert({
requestId,
invoiceId: claimInvoice.invoiceId,
creditNoteNumber: creditNoteResult.creditNoteNumber,
creditNoteDate: creditNoteResult.creditNoteDate || new Date(),
creditNoteAmount: creditNoteResult.creditNoteAmount,
status: 'GENERATED',
confirmedAt: new Date(),
reason: creditNoteData?.reason || 'Claim settlement',
description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`,
});
logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, {
creditNoteNumber: creditNoteResult.creditNoteNumber,
creditNoteAmount: creditNoteResult.creditNoteAmount
});
} else { } else {
// Manual entry - just update the fields // Manual entry - just update the fields
await ClaimCreditNote.upsert({ await ClaimCreditNote.upsert({
requestId, requestId,
invoiceId: claimInvoice.invoiceId, invoiceId: claimInvoice?.invoiceId || undefined, // Allow undefined if no invoice
creditNoteNumber: creditNoteData.creditNoteNumber, creditNoteNumber: creditNoteData.creditNoteNumber,
creditNoteDate: creditNoteData.creditNoteDate || new Date(), creditNoteDate: creditNoteData.creditNoteDate || new Date(),
creditNoteAmount: creditNoteData.creditNoteAmount, creditNoteAmount: creditNoteData.creditNoteAmount,
@ -1790,62 +1991,27 @@ export class DealerClaimService {
throw new Error('This operation is only available for claim management workflows'); throw new Error('This operation is only available for claim management workflows');
} }
// Get Step 8 approval level // Credit Note Confirmation is now an activity log only, not an approval step
// Get Credit Note Confirmation approval level dynamically (by levelName, not hardcoded step number) const requestNumber = (request as any).requestNumber || (request as any).request_number || 'UNKNOWN';
let creditNoteLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Credit Note Confirmation'
}
});
// Fallback: try to find by levelNumber 8 (for backwards compatibility) // Update credit note status to CONFIRMED
if (!creditNoteLevel) {
creditNoteLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelNumber: 8
}
});
}
if (!creditNoteLevel) {
throw new Error(`Credit Note Confirmation approval level not found for request ${requestId}`);
}
// Check if Credit Note Confirmation is already approved
if (creditNoteLevel.status === 'APPROVED') {
logger.info(`[DealerClaimService] Credit Note Confirmation already approved for request ${requestId}`);
// Still send notification to dealer if credit note wasn't sent before
// You can add a flag to track if credit note was already sent
} else {
// Auto-approve Credit Note Confirmation
logger.info(`[DealerClaimService] Auto-approving Credit Note Confirmation (Level ${creditNoteLevel.levelNumber}) for request ${requestId}`);
await this.approvalService.approveLevel(
creditNoteLevel.levelId,
{
action: 'APPROVE',
comments: `Credit note sent to dealer. Credit Note Number: ${creditNote.creditNoteNumber || 'N/A'}. Credit Note Confirmation auto-approved.`,
},
userId,
{
ipAddress: null,
userAgent: 'System Auto-Process',
}
);
logger.info(`[DealerClaimService] Credit Note Confirmation (Level ${creditNoteLevel.levelNumber}) auto-approved successfully for request ${requestId}`);
}
// Update credit note status to SENT
await creditNote.update({ await creditNote.update({
status: 'CONFIRMED', // Or 'SENT' if you have that status status: 'CONFIRMED',
confirmedAt: new Date(), confirmedAt: new Date(),
confirmedBy: userId, confirmedBy: userId,
}); });
// Log Credit Note Confirmation as activity (no approval step needed)
await activityService.log({
requestId,
type: 'status_change',
user: { userId: userId, name: 'Finance Team' },
timestamp: new Date().toISOString(),
action: 'Credit Note Confirmed and Sent',
details: `Credit note sent to dealer. Credit Note Number: ${creditNote.creditNoteNumber || 'N/A'}. Credit Note Amount: ₹${creditNote.creditNoteAmount || 0}. Request: ${requestNumber}`,
});
// Send notification to dealer (you can implement email service here) // Send notification to dealer (you can implement email service here)
// For now, we'll just log it
logger.info(`[DealerClaimService] Credit note sent to dealer`, { logger.info(`[DealerClaimService] Credit note sent to dealer`, {
requestId, requestId,
creditNoteNumber: creditNote.creditNoteNumber, creditNoteNumber: creditNote.creditNoteNumber,
@ -1859,7 +2025,7 @@ export class DealerClaimService {
// dealerName: claimDetails.dealerName, // dealerName: claimDetails.dealerName,
// creditNoteNumber: creditNote.creditNoteNumber, // creditNoteNumber: creditNote.creditNoteNumber,
// creditNoteAmount: creditNote.creditNoteAmount, // creditNoteAmount: creditNote.creditNoteAmount,
// requestNumber: (request as any).requestNumber, // requestNumber: requestNumber,
// }); // });
} catch (error) { } catch (error) {
@ -1869,12 +2035,13 @@ export class DealerClaimService {
} }
/** /**
* Process Step 4: Activity Creation (Auto-processed after Step 3 approval) * Process Activity Creation (now activity log only, not an approval step)
* Creates activity confirmation and sends emails to dealer, requestor, and department lead * Creates activity confirmation and sends emails to dealer, requestor, and department lead
* Logs activity instead of creating/approving approval level
*/ */
async processActivityCreation(requestId: string): Promise<void> { async processActivityCreation(requestId: string): Promise<void> {
try { try {
logger.info(`[DealerClaimService] Processing Step 4: Activity Creation for request ${requestId}`); logger.info(`[DealerClaimService] Processing Activity Creation for request ${requestId}`);
// Get workflow request // Get workflow request
const request = await WorkflowRequest.findByPk(requestId); const request = await WorkflowRequest.findByPk(requestId);
@ -1885,7 +2052,7 @@ export class DealerClaimService {
// Verify this is a claim management workflow // Verify this is a claim management workflow
const workflowType = (request as any).workflowType; const workflowType = (request as any).workflowType;
if (workflowType !== 'CLAIM_MANAGEMENT') { if (workflowType !== 'CLAIM_MANAGEMENT') {
logger.warn(`[DealerClaimService] Skipping Step 4 auto-processing - not a claim management workflow (type: ${workflowType})`); logger.warn(`[DealerClaimService] Skipping Activity Creation - not a claim management workflow (type: ${workflowType})`);
return; return;
} }
@ -1895,29 +2062,6 @@ export class DealerClaimService {
throw new Error(`Claim details not found for request ${requestId}`); throw new Error(`Claim details not found for request ${requestId}`);
} }
// Get Activity Creation approval level dynamically (by levelName, not hardcoded step number)
// This handles cases where approvers are added between steps, causing Activity Creation to shift
let activityCreationLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelName: 'Activity Creation'
}
});
if (!activityCreationLevel) {
// Fallback: try to find by levelNumber 4 (for backwards compatibility)
activityCreationLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelNumber: 4
}
});
if (!activityCreationLevel) {
throw new Error(`Activity Creation approval level not found for request ${requestId}`);
}
}
// Get participants for email notifications // Get participants for email notifications
const initiator = await User.findByPk((request as any).initiatorId); const initiator = await User.findByPk((request as any).initiatorId);
const dealerUser = claimDetails.dealerEmail const dealerUser = claimDetails.dealerEmail
@ -1991,7 +2135,7 @@ export class DealerClaimService {
}); });
} }
// Log activity creation // Log Activity Creation as activity (no approval level needed)
await activityService.log({ await activityService.log({
requestId, requestId,
type: 'status_change', type: 'status_change',
@ -2001,20 +2145,7 @@ export class DealerClaimService {
details: `Activity "${activityName}" created. Activity confirmation email auto-triggered to dealer, requestor, and department lead. IO confirmation to be made.`, details: `Activity "${activityName}" created. Activity confirmation email auto-triggered to dealer, requestor, and department lead. IO confirmation to be made.`,
}); });
// Activity Creation level is already activated (IN_PROGRESS) by the approval service logger.info(`[DealerClaimService] Activity Creation logged as activity for request ${requestId}. Activity creation completed.`);
// Now auto-approve Activity Creation immediately (since it's an auto-step)
const activityCreationLevelId = activityCreationLevel.levelId;
await this.approvalService.approveLevel(
activityCreationLevelId,
{ action: 'APPROVE', comments: 'Activity created automatically. Activity confirmation email sent to dealer, requestor, and department lead.' },
'system',
{
ipAddress: null,
userAgent: 'System Auto-Process'
}
);
logger.info(`[DealerClaimService] Activity Creation (Level ${activityCreationLevel.levelNumber}) auto-approved for request ${requestId}. Activity creation completed.`);
} catch (error) { } catch (error) {
logger.error(`[DealerClaimService] Error processing Step 4 activity creation for request ${requestId}:`, error); logger.error(`[DealerClaimService] Error processing Step 4 activity creation for request ${requestId}:`, error);
throw error; throw error;

View File

@ -147,7 +147,7 @@ export class DMSWebhookService {
} }
// Auto-approve Step 7 and move to Step 8 // Auto-approve Step 7 and move to Step 8
await this.autoApproveStep7(request.requestId, payload.request_number); await this.logEInvoiceGenerationActivity(request.requestId, payload.request_number);
return { return {
success: true, success: true,
@ -197,18 +197,11 @@ export class DMSWebhookService {
}; };
} }
// Find invoice to link credit note // Find invoice to link credit note (optional - credit note can exist without invoice)
const invoice = await ClaimInvoice.findOne({ const invoice = await ClaimInvoice.findOne({
where: { requestId: request.requestId }, where: { requestId: request.requestId },
}); });
if (!invoice) {
return {
success: false,
error: `Invoice not found for request: ${payload.request_number}`,
};
}
// Find or create credit note record // Find or create credit note record
let creditNote = await ClaimCreditNote.findOne({ let creditNote = await ClaimCreditNote.findOne({
where: { requestId: request.requestId }, where: { requestId: request.requestId },
@ -218,11 +211,12 @@ export class DMSWebhookService {
if (!creditNote) { if (!creditNote) {
logger.info('[DMSWebhook] Credit note record not found, creating new credit note from webhook', { logger.info('[DMSWebhook] Credit note record not found, creating new credit note from webhook', {
requestNumber: payload.request_number, requestNumber: payload.request_number,
hasInvoice: !!invoice,
}); });
creditNote = await ClaimCreditNote.create({ creditNote = await ClaimCreditNote.create({
requestId: request.requestId, requestId: request.requestId,
invoiceId: invoice.invoiceId, invoiceId: invoice?.invoiceId || undefined, // Allow undefined if no invoice exists
creditNoteNumber: payload.document_no, creditNoteNumber: payload.document_no,
creditNoteDate: payload.document_date ? new Date(payload.document_date) : new Date(), creditNoteDate: payload.document_date ? new Date(payload.document_date) : new Date(),
creditNoteAmount: payload.total_amount || payload.credit_amount, creditNoteAmount: payload.total_amount || payload.credit_amount,
@ -237,11 +231,12 @@ export class DMSWebhookService {
logger.info('[DMSWebhook] Credit note created successfully from webhook', { logger.info('[DMSWebhook] Credit note created successfully from webhook', {
requestNumber: payload.request_number, requestNumber: payload.request_number,
creditNoteNumber: payload.document_no, creditNoteNumber: payload.document_no,
hasInvoice: !!invoice,
}); });
} else { } else {
// Update existing credit note with DMS response data // Update existing credit note with DMS response data
await creditNote.update({ await creditNote.update({
invoiceId: invoice.invoiceId, invoiceId: invoice?.invoiceId || creditNote.invoiceId, // Preserve existing invoiceId if no invoice found
creditNoteNumber: payload.document_no, creditNoteNumber: payload.document_no,
creditNoteDate: payload.document_date ? new Date(payload.document_date) : new Date(), creditNoteDate: payload.document_date ? new Date(payload.document_date) : new Date(),
creditNoteAmount: payload.total_amount || payload.credit_amount, creditNoteAmount: payload.total_amount || payload.credit_amount,
@ -258,6 +253,7 @@ export class DMSWebhookService {
creditNoteNumber: payload.document_no, creditNoteNumber: payload.document_no,
sapCreditNoteNo: payload.sap_credit_note_no, sapCreditNoteNo: payload.sap_credit_note_no,
irnNo: payload.irn_no, irnNo: payload.irn_no,
hasInvoice: !!invoice,
}); });
} }
@ -326,10 +322,10 @@ export class DMSWebhookService {
} }
/** /**
* Auto-approve Step 7 (E-Invoice Generation) and move to Step 8 * Log E-Invoice Generation as activity (no longer an approval step)
* This is called after invoice is created/updated from DMS webhook * This is called after invoice is created/updated from DMS webhook
*/ */
private async autoApproveStep7(requestId: string, requestNumber: string): Promise<void> { private async logEInvoiceGenerationActivity(requestId: string, requestNumber: string): Promise<void> {
try { try {
// Check if this is a claim management workflow // Check if this is a claim management workflow
const request = await WorkflowRequest.findByPk(requestId); const request = await WorkflowRequest.findByPk(requestId);
@ -347,72 +343,28 @@ export class DMSWebhookService {
return; return;
} }
// Get E-Invoice Generation approval level dynamically (by levelName, not hardcoded step number) // E-Invoice Generation is now an activity log only, not an approval step
let eInvoiceLevel = await ApprovalLevel.findOne({ // Log the activity using the dealerClaimService
where: { const { DealerClaimService } = await import('./dealerClaim.service');
requestId, const dealerClaimService = new DealerClaimService();
levelName: 'E-Invoice Generation', const invoice = await ClaimInvoice.findOne({ where: { requestId } });
}, const invoiceNumber = invoice?.invoiceNumber || 'N/A';
});
await dealerClaimService.logEInvoiceGenerationActivity(requestId, invoiceNumber);
// Fallback: try to find by levelNumber 7 (for backwards compatibility)
if (!eInvoiceLevel) { logger.info('[DMSWebhook] E-Invoice Generation activity logged successfully', {
eInvoiceLevel = await ApprovalLevel.findOne({
where: {
requestId,
levelNumber: 7,
},
});
}
if (!eInvoiceLevel) {
logger.warn('[DMSWebhook] E-Invoice Generation approval level not found', { requestId, requestNumber });
return;
}
// Check if E-Invoice Generation is already approved
if (eInvoiceLevel.status === 'APPROVED') {
logger.info('[DMSWebhook] E-Invoice Generation already approved, skipping auto-approval', {
requestId,
requestNumber,
});
return;
}
// Auto-approve E-Invoice Generation
logger.info(`[DMSWebhook] Auto-approving E-Invoice Generation (Level ${eInvoiceLevel.levelNumber})`, {
requestId, requestId,
requestNumber, requestNumber,
levelId: eInvoiceLevel.levelId, invoiceNumber,
levelNumber: eInvoiceLevel.levelNumber,
});
await this.approvalService.approveLevel(
eInvoiceLevel.levelId,
{
action: 'APPROVE',
comments: `E-Invoice generated via DMS webhook. Invoice Number: ${(await ClaimInvoice.findOne({ where: { requestId } }))?.invoiceNumber || 'N/A'}. E-Invoice Generation auto-approved.`,
},
'system', // System user for auto-approval
{
ipAddress: null,
userAgent: 'DMS-Webhook-System',
}
);
logger.info('[DMSWebhook] E-Invoice Generation auto-approved successfully. Workflow moved to next step', {
requestId,
requestNumber,
levelNumber: eInvoiceLevel.levelNumber,
}); });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DMSWebhook] Error auto-approving Step 7:', { logger.error('[DMSWebhook] Error logging E-Invoice Generation activity:', {
requestId, requestId,
requestNumber, requestNumber,
error: errorMessage, error: errorMessage,
}); });
// Don't throw error - webhook processing should continue even if Step 7 approval fails // Don't throw error - webhook processing should continue even if activity logging fails
// The invoice is already created/updated, which is the primary goal // The invoice is already created/updated, which is the primary goal
} }
} }

View File

@ -29,6 +29,9 @@ export interface SSOConfig {
oktaClientId: string; oktaClientId: string;
oktaClientSecret: string; oktaClientSecret: string;
oktaApiToken?: string; // Optional - SSWS token for Okta Users API oktaApiToken?: string; // Optional - SSWS token for Okta Users API
tanflowBaseUrl?: string; // Tanflow IAM base URL
tanflowClientId?: string; // Tanflow client ID
tanflowClientSecret?: string; // Tanflow client secret
} }
export interface AuthTokens { export interface AuthTokens {