From 031c490de121d031438e3b7980d2b34ee36b0a3b Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Thu, 11 Dec 2025 20:21:36 +0530 Subject: [PATCH] backednd db enhanced table for audit point dedicated table for buget tracking and io --- docs/ERD.mermaid | 507 ++++++++++++++++++ package.json | 2 +- src/config/constants.ts | 2 + src/controllers/admin.controller.ts | 66 ++- src/controllers/dealerClaim.controller.ts | 231 +++++--- src/migrations/2025103004-create-documents.ts | 2 +- .../20251210-create-dealer-claim-tables.ts | 68 --- ...create-claim-invoice-credit-note-tables.ts | 116 ++++ ...1213-drop-claim-details-invoice-columns.ts | 38 ++ ...51214-create-dealer-completion-expenses.ts | 55 ++ src/models/ClaimCreditNote.ts | 123 +++++ src/models/ClaimInvoice.ts | 124 +++++ src/models/DealerClaimDetails.ts | 96 +--- src/models/DealerCompletionDetails.ts | 23 +- src/models/DealerCompletionExpense.ts | 118 ++++ src/models/DealerProposalDetails.ts | 12 +- src/scripts/migrate.ts | 4 + src/services/approval.service.ts | 3 +- src/services/auth.service.ts | 42 ++ src/services/dealerClaim.service.ts | 239 ++++++--- src/services/user.service.ts | 172 ++++-- src/types/auth.types.ts | 7 + 22 files changed, 1639 insertions(+), 411 deletions(-) create mode 100644 docs/ERD.mermaid create mode 100644 src/migrations/20251213-create-claim-invoice-credit-note-tables.ts create mode 100644 src/migrations/20251213-drop-claim-details-invoice-columns.ts create mode 100644 src/migrations/20251214-create-dealer-completion-expenses.ts create mode 100644 src/models/ClaimCreditNote.ts create mode 100644 src/models/ClaimInvoice.ts create mode 100644 src/models/DealerCompletionExpense.ts diff --git a/docs/ERD.mermaid b/docs/ERD.mermaid new file mode 100644 index 0000000..0d7bf90 --- /dev/null +++ b/docs/ERD.mermaid @@ -0,0 +1,507 @@ +erDiagram + users ||--o{ workflow_requests : initiates + users ||--o{ approval_levels : approves + users ||--o{ participants : participates + users ||--o{ work_notes : posts + users ||--o{ documents : uploads + users ||--o{ activities : performs + users ||--o{ notifications : receives + users ||--o{ user_sessions : has + workflow_requests ||--|{ approval_levels : has + workflow_requests ||--o{ participants : involves + workflow_requests ||--o{ documents : contains + workflow_requests ||--o{ work_notes : has + workflow_requests ||--o{ activities : logs + workflow_requests ||--o{ tat_tracking : monitors + workflow_requests ||--o{ notifications : triggers + workflow_requests ||--|| conclusion_remarks : concludes + workflow_requests ||--|| dealer_claim_details : claim_details + workflow_requests ||--|| dealer_proposal_details : proposal_details + dealer_proposal_details ||--o{ dealer_proposal_cost_items : cost_items + workflow_requests ||--|| dealer_completion_details : completion_details + workflow_requests ||--|| internal_orders : internal_order + workflow_requests ||--|| claim_budget_tracking : budget_tracking + workflow_requests ||--|| claim_invoices : claim_invoice + workflow_requests ||--|| claim_credit_notes : claim_credit_note + work_notes ||--o{ work_note_attachments : has + notifications ||--o{ email_logs : sends + notifications ||--o{ sms_logs : sends + workflow_requests ||--o{ report_cache : caches + workflow_requests ||--o{ audit_logs : audits + workflow_requests ||--o{ workflow_templates : templates + users ||--o{ system_settings : updates + + users { + uuid user_id PK + varchar employee_id + varchar okta_sub + varchar email + varchar first_name + varchar last_name + varchar display_name + varchar department + varchar designation + varchar phone + varchar manager + varchar second_email + text job_title + varchar employee_number + varchar postal_address + varchar mobile_phone + jsonb ad_groups + jsonb location + boolean is_active + enum role + timestamp last_login + timestamp created_at + timestamp updated_at + } + + workflow_requests { + uuid request_id PK + varchar request_number + uuid initiator_id FK + varchar template_type + varchar title + text description + enum priority + enum status + integer current_level + integer total_levels + decimal total_tat_hours + timestamp submission_date + timestamp closure_date + text conclusion_remark + text ai_generated_conclusion + boolean is_draft + boolean is_deleted + timestamp created_at + timestamp updated_at + } + + approval_levels { + uuid level_id PK + uuid request_id FK + integer level_number + varchar level_name + uuid approver_id FK + varchar approver_email + varchar approver_name + decimal tat_hours + integer tat_days + enum status + timestamp level_start_time + timestamp level_end_time + timestamp action_date + text comments + text rejection_reason + boolean is_final_approver + decimal elapsed_hours + decimal remaining_hours + decimal tat_percentage_used + timestamp created_at + timestamp updated_at + } + + participants { + uuid participant_id PK + uuid request_id FK + uuid user_id FK + varchar user_email + varchar user_name + enum participant_type + boolean can_comment + boolean can_view_documents + boolean can_download_documents + boolean notification_enabled + uuid added_by FK + timestamp added_at + boolean is_active + } + + documents { + uuid document_id PK + uuid request_id FK + uuid uploaded_by FK + varchar file_name + varchar original_file_name + varchar file_type + varchar file_extension + bigint file_size + varchar file_path + varchar storage_url + varchar mime_type + varchar checksum + boolean is_google_doc + varchar google_doc_url + enum category + integer version + uuid parent_document_id + boolean is_deleted + integer download_count + timestamp uploaded_at + } + + work_notes { + uuid note_id PK + uuid request_id FK + uuid user_id FK + varchar user_name + varchar user_role + text message + varchar message_type + boolean is_priority + boolean has_attachment + uuid parent_note_id + uuid[] mentioned_users + jsonb reactions + boolean is_edited + boolean is_deleted + timestamp created_at + timestamp updated_at + } + + work_note_attachments { + uuid attachment_id PK + uuid note_id FK + varchar file_name + varchar file_type + bigint file_size + varchar file_path + varchar storage_url + boolean is_downloadable + integer download_count + timestamp uploaded_at + } + + activities { + uuid activity_id PK + uuid request_id FK + uuid user_id FK + varchar user_name + varchar activity_type + text activity_description + varchar activity_category + varchar severity + jsonb metadata + boolean is_system_event + varchar ip_address + text user_agent + timestamp created_at + } + + notifications { + uuid notification_id PK + uuid user_id FK + uuid request_id FK + varchar notification_type + varchar title + text message + boolean is_read + enum priority + varchar action_url + boolean action_required + jsonb metadata + varchar[] sent_via + boolean email_sent + boolean sms_sent + boolean push_sent + timestamp read_at + timestamp expires_at + timestamp created_at + } + + tat_tracking { + uuid tracking_id PK + uuid request_id FK + uuid level_id FK + varchar tracking_type + enum tat_status + decimal total_tat_hours + decimal elapsed_hours + decimal remaining_hours + decimal percentage_used + boolean threshold_50_breached + timestamp threshold_50_alerted_at + boolean threshold_80_breached + timestamp threshold_80_alerted_at + boolean threshold_100_breached + timestamp threshold_100_alerted_at + integer alert_count + timestamp last_calculated_at + } + + conclusion_remarks { + uuid conclusion_id PK + uuid request_id FK + text ai_generated_remark + varchar ai_model_used + decimal ai_confidence_score + text final_remark + uuid edited_by FK + boolean is_edited + integer edit_count + jsonb approval_summary + jsonb document_summary + text[] key_discussion_points + timestamp generated_at + timestamp finalized_at + } + + audit_logs { + uuid audit_id PK + uuid user_id FK + varchar entity_type + uuid entity_id + varchar action + varchar action_category + jsonb old_values + jsonb new_values + text changes_summary + varchar ip_address + text user_agent + varchar session_id + varchar request_method + varchar request_url + integer response_status + integer execution_time_ms + timestamp created_at + } + + user_sessions { + uuid session_id PK + uuid user_id FK + varchar session_token + varchar refresh_token + varchar ip_address + text user_agent + varchar device_type + varchar browser + varchar os + timestamp login_at + timestamp last_activity_at + timestamp logout_at + timestamp expires_at + boolean is_active + varchar logout_reason + } + + email_logs { + uuid email_log_id PK + uuid request_id FK + uuid notification_id FK + varchar recipient_email + uuid recipient_user_id FK + text[] cc_emails + text[] bcc_emails + varchar subject + text body + varchar email_type + varchar status + integer send_attempts + timestamp sent_at + timestamp failed_at + text failure_reason + timestamp opened_at + timestamp clicked_at + timestamp created_at + } + + sms_logs { + uuid sms_log_id PK + uuid request_id FK + uuid notification_id FK + varchar recipient_phone + uuid recipient_user_id FK + text message + varchar sms_type + varchar status + integer send_attempts + timestamp sent_at + timestamp delivered_at + timestamp failed_at + text failure_reason + varchar sms_provider + varchar sms_provider_message_id + decimal cost + timestamp created_at + } + + system_settings { + uuid setting_id PK + varchar setting_key + text setting_value + varchar setting_type + varchar setting_category + text description + boolean is_editable + boolean is_sensitive + jsonb validation_rules + text default_value + uuid updated_by FK + timestamp created_at + timestamp updated_at + } + + workflow_templates { + uuid template_id PK + varchar template_name + text template_description + varchar template_category + jsonb approval_levels_config + decimal default_tat_hours + boolean is_active + integer usage_count + uuid created_by FK + timestamp created_at + timestamp updated_at + } + + report_cache { + uuid cache_id PK + varchar report_type + jsonb report_params + jsonb report_data + uuid generated_by FK + timestamp generated_at + timestamp expires_at + integer access_count + timestamp last_accessed_at + } + + dealer_claim_details { + uuid claim_id PK + uuid request_id + varchar activity_name + varchar activity_type + varchar dealer_code + varchar dealer_name + varchar dealer_email + varchar dealer_phone + text dealer_address + date activity_date + varchar location + date period_start_date + date period_end_date + timestamp created_at + timestamp updated_at + } + + dealer_proposal_details { + uuid proposal_id PK + uuid request_id + string proposal_document_path + string proposal_document_url + decimal total_estimated_budget + string timeline_mode + date expected_completion_date + int expected_completion_days + text dealer_comments + date submitted_at + timestamp created_at + timestamp updated_at + } + + dealer_proposal_cost_items { + uuid cost_item_id PK + uuid proposal_id FK + uuid request_id FK + string item_description + decimal amount + int item_order + timestamp created_at + timestamp updated_at + } + + dealer_completion_details { + uuid completion_id PK + uuid request_id + date activity_completion_date + int number_of_participants + decimal total_closed_expenses + date submitted_at + timestamp created_at + timestamp updated_at + } + + dealer_completion_expenses { + uuid expense_id PK + uuid request_id + uuid completion_id + string description + decimal amount + timestamp created_at + timestamp updated_at + } + + internal_orders { + uuid io_id PK + uuid request_id + string io_number + text io_remark + decimal io_available_balance + decimal io_blocked_amount + decimal io_remaining_balance + uuid organized_by FK + date organized_at + string sap_document_number + enum status + timestamp created_at + timestamp updated_at + } + + claim_budget_tracking { + uuid budget_id PK + uuid request_id + decimal initial_estimated_budget + decimal proposal_estimated_budget + date proposal_submitted_at + decimal approved_budget + date approved_at + uuid approved_by FK + decimal io_blocked_amount + date io_blocked_at + decimal closed_expenses + date closed_expenses_submitted_at + decimal final_claim_amount + date final_claim_amount_approved_at + uuid final_claim_amount_approved_by FK + decimal credit_note_amount + date credit_note_issued_at + enum budget_status + string currency + decimal variance_amount + decimal variance_percentage + uuid last_modified_by FK + date last_modified_at + text modification_reason + timestamp created_at + timestamp updated_at + } + + claim_invoices { + uuid invoice_id PK + uuid request_id + string invoice_number + date invoice_date + string dms_number + decimal amount + string status + text description + timestamp created_at + timestamp updated_at + } + + claim_credit_notes { + uuid credit_note_id PK + uuid request_id + string credit_note_number + date credit_note_date + decimal credit_note_amount + string status + text reason + text description + timestamp created_at + timestamp updated_at + } + diff --git a/package.json b/package.json index 4f5923d..51299f6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/server.js", "scripts": { "start": "npm run setup && npm run build && npm run start:prod", - "dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", + "dev": "npm run setup && npm run migrate && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "build": "tsc && tsc-alias", "build:watch": "tsc --watch", diff --git a/src/config/constants.ts b/src/config/constants.ts index 91a9f55..2f9bfc6 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -66,6 +66,8 @@ export const constants = { REFERENCE: 'REFERENCE', FINAL: 'FINAL', OTHER: 'OTHER', + COMPLETION_DOC: 'COMPLETION_DOC', + ACTIVITY_PHOTO: 'ACTIVITY_PHOTO', }, // Work Note Types diff --git a/src/controllers/admin.controller.ts b/src/controllers/admin.controller.ts index 3b57f1f..ff70a90 100644 --- a/src/controllers/admin.controller.ts +++ b/src/controllers/admin.controller.ts @@ -782,15 +782,15 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise => { + // Upload and save completion documents to documents table with COMPLETION_DOC category + for (const file of completionDocumentsFiles) { try { const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + + const uploadResult = await gcsStorageService.uploadFileWithFallback({ + buffer: fileBuffer, + originalName: file.originalname, + mimeType: file.mimetype, + requestNumber: requestNumber, + fileType: 'documents' + }); + const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); - await Document.create({ + // Save to documents table + const doc = await Document.create({ requestId, uploadedBy: userId, fileName: path.basename(file.filename || file.originalname), @@ -342,109 +316,208 @@ export class DealerClaimController { checksum, isGoogleDoc: false, googleDocUrl: null as any, - category, + category: constants.DOCUMENT_CATEGORIES.COMPLETION_DOC, version: 1, parentDocumentId: null as any, isDeleted: false, downloadCount: 0, } as any); - logger.info(`[DealerClaimController] Created document entry for ${file.originalname} with category ${category}`); - } catch (docError) { - logger.error(`[DealerClaimController] Error creating document entry for ${file.originalname}:`, docError); - // Don't fail the entire request if document entry creation fails - } - }; - - // Upload completion documents (category: APPROVAL - these are proof of completion) - for (const file of completionDocumentsFiles) { - try { - const uploadResult = await gcsStorageService.uploadFileWithFallback({ - buffer: file.buffer, - originalName: file.originalname, - mimeType: file.mimetype, - requestNumber: requestNumber, - fileType: 'documents' - }); completionDocuments.push({ + documentId: doc.documentId, name: file.originalname, url: uploadResult.storageUrl, size: file.size, type: file.mimetype, }); - // Save to documents table - await createDocumentEntry(file, uploadResult, 'APPROVAL'); + + // Cleanup local file if exists + if (file.path && fs.existsSync(file.path)) { + try { + fs.unlinkSync(file.path); + } catch (unlinkError) { + logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`); + } + } } catch (error) { logger.error(`[DealerClaimController] Error uploading completion document ${file.originalname}:`, error); } } - // Upload activity photos (category: SUPPORTING - supporting evidence) + // Upload and save activity photos to documents table with ACTIVITY_PHOTO category for (const file of activityPhotosFiles) { try { + const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); + const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + const uploadResult = await gcsStorageService.uploadFileWithFallback({ - buffer: file.buffer, + buffer: fileBuffer, originalName: file.originalname, mimeType: file.mimetype, requestNumber: requestNumber, fileType: 'attachments' }); + + const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); + + // Save to documents table + const doc = await Document.create({ + requestId, + uploadedBy: userId, + fileName: path.basename(file.filename || file.originalname), + originalFileName: file.originalname, + fileType: extension, + fileExtension: extension, + fileSize: file.size, + filePath: uploadResult.filePath, + storageUrl: uploadResult.storageUrl, + mimeType: file.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: constants.DOCUMENT_CATEGORIES.ACTIVITY_PHOTO, + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + activityPhotos.push({ + documentId: doc.documentId, name: file.originalname, url: uploadResult.storageUrl, size: file.size, type: file.mimetype, }); - // Save to documents table - await createDocumentEntry(file, uploadResult, 'SUPPORTING'); + + // Cleanup local file if exists + if (file.path && fs.existsSync(file.path)) { + try { + fs.unlinkSync(file.path); + } catch (unlinkError) { + logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`); + } + } } catch (error) { logger.error(`[DealerClaimController] Error uploading activity photo ${file.originalname}:`, error); } } - // Upload invoices/receipts if provided (category: SUPPORTING - supporting financial documents) + // Upload and save invoices/receipts to documents table with SUPPORTING category const invoicesReceipts: any[] = []; for (const file of invoicesReceiptsFiles) { try { + const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); + const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + const uploadResult = await gcsStorageService.uploadFileWithFallback({ - buffer: file.buffer, + buffer: fileBuffer, originalName: file.originalname, mimeType: file.mimetype, requestNumber: requestNumber, fileType: 'attachments' }); + + const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); + + // Save to documents table + const doc = await Document.create({ + requestId, + uploadedBy: userId, + fileName: path.basename(file.filename || file.originalname), + originalFileName: file.originalname, + fileType: extension, + fileExtension: extension, + fileSize: file.size, + filePath: uploadResult.filePath, + storageUrl: uploadResult.storageUrl, + mimeType: file.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: constants.DOCUMENT_CATEGORIES.SUPPORTING, + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + invoicesReceipts.push({ + documentId: doc.documentId, name: file.originalname, url: uploadResult.storageUrl, size: file.size, type: file.mimetype, }); - // Save to documents table - await createDocumentEntry(file, uploadResult, 'SUPPORTING'); + + // Cleanup local file if exists + if (file.path && fs.existsSync(file.path)) { + try { + fs.unlinkSync(file.path); + } catch (unlinkError) { + logger.warn(`[DealerClaimController] Failed to delete local file ${file.path}`); + } + } } catch (error) { logger.error(`[DealerClaimController] Error uploading invoice/receipt ${file.originalname}:`, error); } } - // Upload attendance sheet if provided (category: SUPPORTING - supporting evidence) + // Upload and save attendance sheet to documents table with SUPPORTING category let attendanceSheet: any = null; if (attendanceSheetFile) { try { + const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from('')); + const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); + const uploadResult = await gcsStorageService.uploadFileWithFallback({ - buffer: attendanceSheetFile.buffer, + buffer: fileBuffer, originalName: attendanceSheetFile.originalname, mimeType: attendanceSheetFile.mimetype, requestNumber: requestNumber, fileType: 'attachments' }); + + const extension = path.extname(attendanceSheetFile.originalname).replace('.', '').toLowerCase(); + + // Save to documents table + const doc = await Document.create({ + requestId, + uploadedBy: userId, + fileName: path.basename(attendanceSheetFile.filename || attendanceSheetFile.originalname), + originalFileName: attendanceSheetFile.originalname, + fileType: extension, + fileExtension: extension, + fileSize: attendanceSheetFile.size, + filePath: uploadResult.filePath, + storageUrl: uploadResult.storageUrl, + mimeType: attendanceSheetFile.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: constants.DOCUMENT_CATEGORIES.SUPPORTING, + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + attendanceSheet = { + documentId: doc.documentId, name: attendanceSheetFile.originalname, url: uploadResult.storageUrl, size: attendanceSheetFile.size, type: attendanceSheetFile.mimetype, }; - // Save to documents table - await createDocumentEntry(attendanceSheetFile, uploadResult, 'SUPPORTING'); + + // Cleanup local file if exists + if (attendanceSheetFile.path && fs.existsSync(attendanceSheetFile.path)) { + try { + fs.unlinkSync(attendanceSheetFile.path); + } catch (unlinkError) { + logger.warn(`[DealerClaimController] Failed to delete local file ${attendanceSheetFile.path}`); + } + } } catch (error) { logger.error(`[DealerClaimController] Error uploading attendance sheet:`, error); } @@ -455,8 +528,6 @@ export class DealerClaimController { numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined, closedExpenses: parsedClosedExpenses, totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0, - completionDocuments, - activityPhotos, invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined, attendanceSheet: attendanceSheet || undefined, }); diff --git a/src/migrations/2025103004-create-documents.ts b/src/migrations/2025103004-create-documents.ts index a595b69..4c89b5f 100644 --- a/src/migrations/2025103004-create-documents.ts +++ b/src/migrations/2025103004-create-documents.ts @@ -4,7 +4,7 @@ export async function up(queryInterface: QueryInterface): Promise { await queryInterface.sequelize.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_document_category') THEN - CREATE TYPE enum_document_category AS ENUM ('SUPPORTING','APPROVAL','REFERENCE','FINAL','OTHER'); + CREATE TYPE enum_document_category AS ENUM ('SUPPORTING','APPROVAL','REFERENCE','FINAL','OTHER','COMPLETION_DOC','ACTIVITY_PHOTO'); END IF; END$$;`); diff --git a/src/migrations/20251210-create-dealer-claim-tables.ts b/src/migrations/20251210-create-dealer-claim-tables.ts index 4ac5d52..7154aa0 100644 --- a/src/migrations/20251210-create-dealer-claim-tables.ts +++ b/src/migrations/20251210-create-dealer-claim-tables.ts @@ -63,58 +63,6 @@ export async function up(queryInterface: QueryInterface): Promise { type: DataTypes.DATEONLY, allowNull: true }, - estimated_budget: { - type: DataTypes.DECIMAL(15, 2), - allowNull: true - }, - closed_expenses: { - type: DataTypes.DECIMAL(15, 2), - allowNull: true - }, - io_number: { - type: DataTypes.STRING(50), - allowNull: true - }, - io_available_balance: { - type: DataTypes.DECIMAL(15, 2), - allowNull: true - }, - io_blocked_amount: { - type: DataTypes.DECIMAL(15, 2), - allowNull: true - }, - io_remaining_balance: { - type: DataTypes.DECIMAL(15, 2), - allowNull: true - }, - sap_document_number: { - type: DataTypes.STRING(100), - allowNull: true - }, - dms_number: { - type: DataTypes.STRING(100), - allowNull: true - }, - e_invoice_number: { - type: DataTypes.STRING(100), - allowNull: true - }, - e_invoice_date: { - type: DataTypes.DATEONLY, - allowNull: true - }, - credit_note_number: { - type: DataTypes.STRING(100), - allowNull: true - }, - credit_note_date: { - type: DataTypes.DATEONLY, - allowNull: true - }, - credit_note_amount: { - type: DataTypes.DECIMAL(15, 2), - allowNull: true - }, created_at: { type: DataTypes.DATE, allowNull: false, @@ -165,10 +113,6 @@ export async function up(queryInterface: QueryInterface): Promise { type: DataTypes.STRING(500), allowNull: true }, - cost_breakup: { - type: DataTypes.JSONB, - allowNull: true - }, total_estimated_budget: { type: DataTypes.DECIMAL(15, 2), allowNull: true @@ -236,22 +180,10 @@ export async function up(queryInterface: QueryInterface): Promise { type: DataTypes.INTEGER, allowNull: true }, - closed_expenses: { - type: DataTypes.JSONB, - allowNull: true - }, total_closed_expenses: { type: DataTypes.DECIMAL(15, 2), allowNull: true }, - completion_documents: { - type: DataTypes.JSONB, - allowNull: true - }, - activity_photos: { - type: DataTypes.JSONB, - allowNull: true - }, submitted_at: { type: DataTypes.DATE, allowNull: true diff --git a/src/migrations/20251213-create-claim-invoice-credit-note-tables.ts b/src/migrations/20251213-create-claim-invoice-credit-note-tables.ts new file mode 100644 index 0000000..4e2da5d --- /dev/null +++ b/src/migrations/20251213-create-claim-invoice-credit-note-tables.ts @@ -0,0 +1,116 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable('claim_invoices', { + invoice_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + unique: true, // one invoice per request (adjust later if multiples needed) + references: { model: 'workflow_requests', key: 'request_id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + invoice_number: { + type: DataTypes.STRING(100), + allowNull: true, + }, + invoice_date: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + dms_number: { + type: DataTypes.STRING(100), + allowNull: true, + }, + amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + }, + status: { + type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED + allowNull: true, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }); + + await queryInterface.addIndex('claim_invoices', ['request_id'], { name: 'idx_claim_invoices_request_id', unique: true }); + await queryInterface.addIndex('claim_invoices', ['invoice_number'], { name: 'idx_claim_invoices_invoice_number' }); + await queryInterface.addIndex('claim_invoices', ['dms_number'], { name: 'idx_claim_invoices_dms_number' }); + + await queryInterface.createTable('claim_credit_notes', { + credit_note_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + unique: true, // one credit note per request (adjust later if multiples needed) + references: { model: 'workflow_requests', key: 'request_id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + credit_note_number: { + type: DataTypes.STRING(100), + allowNull: true, + }, + credit_note_date: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + credit_note_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + }, + status: { + type: DataTypes.STRING(50), // e.g., PENDING, GENERATED, SENT, FAILED, CANCELLED + allowNull: true, + }, + reason: { + type: DataTypes.TEXT, + allowNull: true, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }); + + await queryInterface.addIndex('claim_credit_notes', ['request_id'], { name: 'idx_claim_credit_notes_request_id', unique: true }); + await queryInterface.addIndex('claim_credit_notes', ['credit_note_number'], { name: 'idx_claim_credit_notes_number' }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('claim_credit_notes'); + await queryInterface.dropTable('claim_invoices'); +} + diff --git a/src/migrations/20251213-drop-claim-details-invoice-columns.ts b/src/migrations/20251213-drop-claim-details-invoice-columns.ts new file mode 100644 index 0000000..15e21a9 --- /dev/null +++ b/src/migrations/20251213-drop-claim-details-invoice-columns.ts @@ -0,0 +1,38 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.removeColumn('dealer_claim_details', 'dms_number'); + await queryInterface.removeColumn('dealer_claim_details', 'e_invoice_number'); + await queryInterface.removeColumn('dealer_claim_details', 'e_invoice_date'); + await queryInterface.removeColumn('dealer_claim_details', 'credit_note_number'); + await queryInterface.removeColumn('dealer_claim_details', 'credit_note_date'); + await queryInterface.removeColumn('dealer_claim_details', 'credit_note_amount'); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.addColumn('dealer_claim_details', 'dms_number', { + type: DataTypes.STRING(100), + allowNull: true, + }); + await queryInterface.addColumn('dealer_claim_details', 'e_invoice_number', { + type: DataTypes.STRING(100), + allowNull: true, + }); + await queryInterface.addColumn('dealer_claim_details', 'e_invoice_date', { + type: DataTypes.DATEONLY, + allowNull: true, + }); + await queryInterface.addColumn('dealer_claim_details', 'credit_note_number', { + type: DataTypes.STRING(100), + allowNull: true, + }); + await queryInterface.addColumn('dealer_claim_details', 'credit_note_date', { + type: DataTypes.DATEONLY, + allowNull: true, + }); + await queryInterface.addColumn('dealer_claim_details', 'credit_note_amount', { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + }); +} + diff --git a/src/migrations/20251214-create-dealer-completion-expenses.ts b/src/migrations/20251214-create-dealer-completion-expenses.ts new file mode 100644 index 0000000..0be5526 --- /dev/null +++ b/src/migrations/20251214-create-dealer-completion-expenses.ts @@ -0,0 +1,55 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable('dealer_completion_expenses', { + expense_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + references: { model: 'workflow_requests', key: 'request_id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + completion_id: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'dealer_completion_details', key: 'completion_id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + description: { + type: DataTypes.STRING(500), + allowNull: false, + }, + amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }); + + await queryInterface.addIndex('dealer_completion_expenses', ['request_id'], { + name: 'idx_dealer_completion_expenses_request_id', + }); + await queryInterface.addIndex('dealer_completion_expenses', ['completion_id'], { + name: 'idx_dealer_completion_expenses_completion_id', + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('dealer_completion_expenses'); +} + diff --git a/src/models/ClaimCreditNote.ts b/src/models/ClaimCreditNote.ts new file mode 100644 index 0000000..8b43b7a --- /dev/null +++ b/src/models/ClaimCreditNote.ts @@ -0,0 +1,123 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface ClaimCreditNoteAttributes { + creditNoteId: string; + requestId: string; + creditNoteNumber?: string; + creditNoteDate?: Date; + creditNoteAmount?: number; + status?: string; + reason?: string; + description?: string; + createdAt: Date; + updatedAt: Date; +} + +interface ClaimCreditNoteCreationAttributes extends Optional {} + +class ClaimCreditNote extends Model implements ClaimCreditNoteAttributes { + public creditNoteId!: string; + public requestId!: string; + public creditNoteNumber?: string; + public creditNoteDate?: Date; + public creditNoteAmount?: number; + public status?: string; + public reason?: string; + public description?: string; + public createdAt!: Date; + public updatedAt!: Date; +} + +ClaimCreditNote.init( + { + creditNoteId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'credit_note_id', + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + creditNoteNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'credit_note_number', + }, + creditNoteDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'credit_note_date', + }, + creditNoteAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'credit_note_amount', + }, + status: { + type: DataTypes.STRING(50), + allowNull: true, + field: 'status', + }, + reason: { + type: DataTypes.TEXT, + allowNull: true, + field: 'reason', + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + field: 'description', + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at', + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at', + }, + }, + { + sequelize, + modelName: 'ClaimCreditNote', + tableName: 'claim_credit_notes', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { unique: true, fields: ['request_id'], name: 'idx_claim_credit_notes_request_id' }, + { fields: ['credit_note_number'], name: 'idx_claim_credit_notes_number' }, + ], + } +); + +WorkflowRequest.hasOne(ClaimCreditNote, { + as: 'claimCreditNote', + foreignKey: 'requestId', + sourceKey: 'requestId', +}); + +ClaimCreditNote.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId', +}); + +export { ClaimCreditNote }; + diff --git a/src/models/ClaimInvoice.ts b/src/models/ClaimInvoice.ts new file mode 100644 index 0000000..a400619 --- /dev/null +++ b/src/models/ClaimInvoice.ts @@ -0,0 +1,124 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface ClaimInvoiceAttributes { + invoiceId: string; + requestId: string; + invoiceNumber?: string; + invoiceDate?: Date; + dmsNumber?: string; + amount?: number; + status?: string; + description?: string; + createdAt: Date; + updatedAt: Date; +} + +interface ClaimInvoiceCreationAttributes extends Optional {} + +class ClaimInvoice extends Model implements ClaimInvoiceAttributes { + public invoiceId!: string; + public requestId!: string; + public invoiceNumber?: string; + public invoiceDate?: Date; + public dmsNumber?: string; + public amount?: number; + public status?: string; + public description?: string; + public createdAt!: Date; + public updatedAt!: Date; +} + +ClaimInvoice.init( + { + invoiceId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'invoice_id', + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + invoiceNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'invoice_number', + }, + invoiceDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'invoice_date', + }, + dmsNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'dms_number', + }, + amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'amount', + }, + status: { + type: DataTypes.STRING(50), + allowNull: true, + field: 'status', + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + field: 'description', + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at', + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at', + }, + }, + { + sequelize, + modelName: 'ClaimInvoice', + tableName: 'claim_invoices', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { unique: true, fields: ['request_id'], name: 'idx_claim_invoices_request_id' }, + { fields: ['invoice_number'], name: 'idx_claim_invoices_invoice_number' }, + { fields: ['dms_number'], name: 'idx_claim_invoices_dms_number' }, + ], + } +); + +WorkflowRequest.hasOne(ClaimInvoice, { + as: 'claimInvoice', + foreignKey: 'requestId', + sourceKey: 'requestId', +}); + +ClaimInvoice.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId', +}); + +export { ClaimInvoice }; + diff --git a/src/models/DealerClaimDetails.ts b/src/models/DealerClaimDetails.ts index 8fb82be..f32912e 100644 --- a/src/models/DealerClaimDetails.ts +++ b/src/models/DealerClaimDetails.ts @@ -16,25 +16,11 @@ interface DealerClaimDetailsAttributes { location?: string; periodStartDate?: Date; periodEndDate?: Date; - estimatedBudget?: number; - closedExpenses?: number; - ioNumber?: string; - // Note: ioRemark moved to internal_orders table - ioAvailableBalance?: number; - ioBlockedAmount?: number; - ioRemainingBalance?: number; - sapDocumentNumber?: string; - dmsNumber?: string; - eInvoiceNumber?: string; - eInvoiceDate?: Date; - creditNoteNumber?: string; - creditNoteDate?: Date; - creditNoteAmount?: number; createdAt: Date; updatedAt: Date; } -interface DealerClaimDetailsCreationAttributes extends Optional {} +interface DealerClaimDetailsCreationAttributes extends Optional {} class DealerClaimDetails extends Model implements DealerClaimDetailsAttributes { public claimId!: string; @@ -50,20 +36,6 @@ class DealerClaimDetails extends Model {} +interface DealerCompletionDetailsCreationAttributes extends Optional {} class DealerCompletionDetails extends Model implements DealerCompletionDetailsAttributes { public completionId!: string; public requestId!: string; public activityCompletionDate!: Date; public numberOfParticipants?: number; - public closedExpenses?: any; public totalClosedExpenses?: number; - public completionDocuments?: any; - public activityPhotos?: any; public submittedAt?: Date; public createdAt!: Date; public updatedAt!: Date; @@ -62,26 +56,11 @@ DealerCompletionDetails.init( allowNull: true, field: 'number_of_participants' }, - closedExpenses: { - type: DataTypes.JSONB, - allowNull: true, - field: 'closed_expenses' - }, totalClosedExpenses: { type: DataTypes.DECIMAL(15, 2), allowNull: true, field: 'total_closed_expenses' }, - completionDocuments: { - type: DataTypes.JSONB, - allowNull: true, - field: 'completion_documents' - }, - activityPhotos: { - type: DataTypes.JSONB, - allowNull: true, - field: 'activity_photos' - }, submittedAt: { type: DataTypes.DATE, allowNull: true, diff --git a/src/models/DealerCompletionExpense.ts b/src/models/DealerCompletionExpense.ts new file mode 100644 index 0000000..7c164f6 --- /dev/null +++ b/src/models/DealerCompletionExpense.ts @@ -0,0 +1,118 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; +import { DealerCompletionDetails } from './DealerCompletionDetails'; + +interface DealerCompletionExpenseAttributes { + expenseId: string; + requestId: string; + completionId?: string | null; + description: string; + amount: number; + createdAt: Date; + updatedAt: Date; +} + +interface DealerCompletionExpenseCreationAttributes extends Optional {} + +class DealerCompletionExpense extends Model implements DealerCompletionExpenseAttributes { + public expenseId!: string; + public requestId!: string; + public completionId?: string | null; + public description!: string; + public amount!: number; + public createdAt!: Date; + public updatedAt!: Date; +} + +DealerCompletionExpense.init( + { + expenseId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'expense_id', + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id', + }, + }, + completionId: { + type: DataTypes.UUID, + allowNull: true, + field: 'completion_id', + references: { + model: 'dealer_completion_details', + key: 'completion_id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + description: { + type: DataTypes.STRING(500), + allowNull: false, + field: 'description', + }, + amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'amount', + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at', + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at', + }, + }, + { + sequelize, + modelName: 'DealerCompletionExpense', + tableName: 'dealer_completion_expenses', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { fields: ['request_id'], name: 'idx_dealer_completion_expenses_request_id' }, + { fields: ['completion_id'], name: 'idx_dealer_completion_expenses_completion_id' }, + ], + } +); + +WorkflowRequest.hasMany(DealerCompletionExpense, { + as: 'completionExpenses', + foreignKey: 'requestId', + sourceKey: 'requestId', +}); + +DealerCompletionExpense.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId', +}); + +DealerCompletionDetails.hasMany(DealerCompletionExpense, { + as: 'expenses', + foreignKey: 'completionId', + sourceKey: 'completionId', +}); + +DealerCompletionExpense.belongsTo(DealerCompletionDetails, { + as: 'completion', + foreignKey: 'completionId', + targetKey: 'completionId', +}); + +export { DealerCompletionExpense }; + diff --git a/src/models/DealerProposalDetails.ts b/src/models/DealerProposalDetails.ts index a6e64d5..edb7565 100644 --- a/src/models/DealerProposalDetails.ts +++ b/src/models/DealerProposalDetails.ts @@ -7,7 +7,7 @@ interface DealerProposalDetailsAttributes { requestId: string; proposalDocumentPath?: string; proposalDocumentUrl?: string; - costBreakup?: any; // JSONB array of {description, amount} + // costBreakup removed - now using dealer_proposal_cost_items table totalEstimatedBudget?: number; timelineMode?: 'date' | 'days'; expectedCompletionDate?: Date; @@ -18,14 +18,14 @@ interface DealerProposalDetailsAttributes { updatedAt: Date; } -interface DealerProposalDetailsCreationAttributes extends Optional {} +interface DealerProposalDetailsCreationAttributes extends Optional {} class DealerProposalDetails extends Model implements DealerProposalDetailsAttributes { public proposalId!: string; public requestId!: string; public proposalDocumentPath?: string; public proposalDocumentUrl?: string; - public costBreakup?: any; + // costBreakup removed - now using dealer_proposal_cost_items table public totalEstimatedBudget?: number; public timelineMode?: 'date' | 'days'; public expectedCompletionDate?: Date; @@ -66,11 +66,7 @@ DealerProposalDetails.init( allowNull: true, field: 'proposal_document_url' }, - costBreakup: { - type: DataTypes.JSONB, - allowNull: true, - field: 'cost_breakup' - }, + // costBreakup field removed - now using dealer_proposal_cost_items table totalEstimatedBudget: { type: DataTypes.DECIMAL(15, 2), allowNull: true, diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index aea6b3b..203fe18 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -29,6 +29,8 @@ import * as m25 from '../migrations/20251210-create-dealer-claim-tables'; import * as m26 from '../migrations/20251210-create-proposal-cost-items-table'; import * as m27 from '../migrations/20251211-create-internal-orders-table'; import * as m28 from '../migrations/20251211-create-claim-budget-tracking-table'; +import * as m29 from '../migrations/20251213-create-claim-invoice-credit-note-tables'; +import * as m30 from '../migrations/20251214-create-dealer-completion-expenses'; interface Migration { name: string; @@ -72,6 +74,8 @@ const migrations: Migration[] = [ { name: '20251210-create-proposal-cost-items-table', module: m26 }, { name: '20251211-create-internal-orders-table', module: m27 }, { name: '20251211-create-claim-budget-tracking-table', module: m28 }, + { name: '20251213-create-claim-invoice-credit-note-tables', module: m29 }, + { name: '20251214-create-dealer-completion-expenses', module: m30 }, ]; /** diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index 0f1c76d..cd3065d 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -453,7 +453,8 @@ export class ApprovalService { const dealerClaimService = new DealerClaimService(); await dealerClaimService.processEInvoiceGeneration(level.requestId); logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`); - // Skip notification for system auto-processed step - continue to return updatedLevel at end + // Skip notification for system auto-processed step + return updatedLevel; } catch (step7Error) { logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, step7Error); // Don't fail the Step 6 approval if Step 7 processing fails - log and continue diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 6e3513d..5a1c1a8 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -122,6 +122,11 @@ export class AuthService { designation: profile.title || profile.designation || undefined, phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined, manager: profile.manager || undefined, // Store manager name if available + jobTitle: profile.title || undefined, + postalAddress: profile.postalAddress || undefined, + mobilePhone: profile.mobilePhone || undefined, + secondEmail: profile.secondEmail || profile.second_email || undefined, + adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined, }; // Validate required fields @@ -135,8 +140,16 @@ export class AuthService { email: userData.email, employeeId: userData.employeeId || 'not provided', hasManager: !!userData.manager, + manager: userData.manager || 'not provided', hasDepartment: !!userData.department, hasDesignation: !!userData.designation, + designation: userData.designation || 'not provided', + hasJobTitle: !!userData.jobTitle, + jobTitle: userData.jobTitle || 'not provided', + hasTitle: !!(userData.jobTitle || userData.designation), + hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0, + adGroupsCount: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.length : 0, + adGroups: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.slice(0, 5) : 'none', // Log first 5 groups }); return userData; @@ -194,10 +207,26 @@ export class AuthService { } if (oktaUser.title || oktaUser.designation) { userData.designation = oktaUser.title || oktaUser.designation; + userData.jobTitle = oktaUser.title || oktaUser.designation; } if (oktaUser.phone_number || oktaUser.phone) { userData.phone = oktaUser.phone_number || oktaUser.phone; } + if (oktaUser.manager) { + userData.manager = oktaUser.manager; + } + if (oktaUser.mobilePhone) { + userData.mobilePhone = oktaUser.mobilePhone; + } + if (oktaUser.address || oktaUser.postalAddress) { + userData.postalAddress = oktaUser.address || oktaUser.postalAddress; + } + if (oktaUser.secondEmail) { + userData.secondEmail = oktaUser.secondEmail; + } + if (Array.isArray(oktaUser.memberOf)) { + userData.adGroups = oktaUser.memberOf; + } return userData; } @@ -530,8 +559,15 @@ export class AuthService { logger.info('User data extracted from Okta', { email: userData.email, + employeeId: userData.employeeId || 'not provided', hasEmployeeId: !!userData.employeeId, hasName: !!userData.displayName, + hasManager: !!(userData as any).manager, + manager: (userData as any).manager || 'not provided', + hasDepartment: !!userData.department, + hasDesignation: !!userData.designation, + hasJobTitle: !!userData.jobTitle, + source: usersApiResponse ? 'Users API' : 'userinfo endpoint', }); // Step 4: Create/update user in our database @@ -699,8 +735,14 @@ export class AuthService { email: userData.email, employeeId: userData.employeeId || 'not provided', hasManager: !!(userData as any).manager, + manager: (userData as any).manager || 'not provided', hasDepartment: !!userData.department, hasDesignation: !!userData.designation, + hasJobTitle: !!userData.jobTitle, + hasPostalAddress: !!userData.postalAddress, + hasMobilePhone: !!userData.mobilePhone, + hasSecondEmail: !!userData.secondEmail, + hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0, source: usersApiResponse ? 'Users API' : 'userinfo endpoint', }); diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 646631f..3c1f571 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -4,6 +4,10 @@ import { DealerProposalDetails } from '../models/DealerProposalDetails'; import { DealerCompletionDetails } from '../models/DealerCompletionDetails'; import { DealerProposalCostItem } from '../models/DealerProposalCostItem'; import { InternalOrder, IOStatus } from '../models/InternalOrder'; +import { ClaimBudgetTracking, BudgetStatus } from '../models/ClaimBudgetTracking'; +import { ClaimInvoice } from '../models/ClaimInvoice'; +import { ClaimCreditNote } from '../models/ClaimCreditNote'; +import { DealerCompletionExpense } from '../models/DealerCompletionExpense'; import { ApprovalLevel } from '../models/ApprovalLevel'; import { Participant } from '../models/Participant'; import { User } from '../models/User'; @@ -83,7 +87,14 @@ export class DealerClaimService { location: claimData.location, periodStartDate: claimData.periodStartDate, periodEndDate: claimData.periodEndDate, - estimatedBudget: claimData.estimatedBudget, + }); + + // Initialize budget tracking with initial estimated budget (if provided) + await ClaimBudgetTracking.upsert({ + requestId: workflowRequest.requestId, + initialEstimatedBudget: claimData.estimatedBudget, + budgetStatus: BudgetStatus.DRAFT, + currency: 'INR', }); // Create 8 approval levels for claim management workflow @@ -702,7 +713,7 @@ export class DealerClaimService { if (proposalDetails) { const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : proposalDetails; - // Get cost items from separate table (preferred) or fallback to JSONB + // Get cost items from separate table (dealer_proposal_cost_items) let costBreakup: any[] = []; if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) { // Use cost items from separate table @@ -710,18 +721,8 @@ export class DealerClaimService { description: item.itemDescription || item.description, amount: Number(item.amount) || 0 })); - } else if (proposalData.costBreakup) { - // Fallback to JSONB field for backward compatibility - if (Array.isArray(proposalData.costBreakup)) { - costBreakup = proposalData.costBreakup; - } else if (typeof proposalData.costBreakup === 'string') { - try { - costBreakup = JSON.parse(proposalData.costBreakup); - } catch (e) { - logger.warn('[DealerClaimService] Failed to parse costBreakup JSON:', e); - } - } } + // Note: costBreakup JSONB field has been removed - only using separate table now transformedProposalDetails = { ...proposalData, @@ -742,12 +743,63 @@ export class DealerClaimService { serializedInternalOrder = (internalOrder as any).toJSON ? (internalOrder as any).toJSON() : internalOrder; } + // Fetch Budget Tracking details + const budgetTracking = await ClaimBudgetTracking.findOne({ + where: { requestId } + }); + + // Fetch Invoice details + const claimInvoice = await ClaimInvoice.findOne({ + where: { requestId } + }); + + // Fetch Credit Note details + const claimCreditNote = await ClaimCreditNote.findOne({ + where: { requestId } + }); + + // Fetch Completion Expenses (individual expense items) + const completionExpenses = await DealerCompletionExpense.findAll({ + where: { requestId }, + order: [['createdAt', 'ASC']] + }); + + // Serialize new tables + let serializedBudgetTracking = null; + if (budgetTracking) { + serializedBudgetTracking = (budgetTracking as any).toJSON ? (budgetTracking as any).toJSON() : budgetTracking; + } + + let serializedInvoice = null; + if (claimInvoice) { + serializedInvoice = (claimInvoice as any).toJSON ? (claimInvoice as any).toJSON() : claimInvoice; + } + + let serializedCreditNote = null; + if (claimCreditNote) { + serializedCreditNote = (claimCreditNote as any).toJSON ? (claimCreditNote as any).toJSON() : claimCreditNote; + } + + // Transform completion expenses to array format for frontend + const expensesBreakdown = completionExpenses.map((expense: any) => { + const expenseData = expense.toJSON ? expense.toJSON() : expense; + return { + description: expenseData.description || '', + amount: Number(expenseData.amount) || 0 + }; + }); + return { request: (request as any).toJSON ? (request as any).toJSON() : request, claimDetails: serializedClaimDetails, proposalDetails: transformedProposalDetails, completionDetails: serializedCompletionDetails, internalOrder: serializedInternalOrder, + // New normalized tables + budgetTracking: serializedBudgetTracking, + invoice: serializedInvoice, + creditNote: serializedCreditNote, + completionExpenses: expensesBreakdown, // Array of expense items }; } catch (error) { logger.error('[DealerClaimService] Error getting claim details:', error); @@ -781,13 +833,12 @@ export class DealerClaimService { throw new Error('Proposal can only be submitted at step 1'); } - // Save proposal details (keep costBreakup in JSONB for backward compatibility) + // Save proposal details (costBreakup removed - now using separate table) const [proposal] = await DealerProposalDetails.upsert({ requestId, proposalDocumentPath: proposalData.proposalDocumentPath, proposalDocumentUrl: proposalData.proposalDocumentUrl, - // Keep costBreakup in JSONB for backward compatibility - costBreakup: proposalData.costBreakup.length > 0 ? proposalData.costBreakup : null, + // costBreakup field removed - now using dealer_proposal_cost_items table totalEstimatedBudget: proposalData.totalEstimatedBudget, timelineMode: proposalData.timelineMode, expectedCompletionDate: proposalData.expectedCompletionDate, @@ -843,11 +894,14 @@ export class DealerClaimService { logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`); } - // Update estimated budget in claim details - await DealerClaimDetails.update( - { estimatedBudget: proposalData.totalEstimatedBudget }, - { where: { requestId } } - ); + // Update budget tracking with proposal estimate + await ClaimBudgetTracking.upsert({ + requestId, + proposalEstimatedBudget: proposalData.totalEstimatedBudget, + proposalSubmittedAt: new Date(), + budgetStatus: BudgetStatus.PROPOSED, + currency: 'INR', + }); // Approve step 1 and move to step 2 const level1 = await ApprovalLevel.findOne({ @@ -880,8 +934,6 @@ export class DealerClaimService { numberOfParticipants?: number; closedExpenses: any[]; totalClosedExpenses: number; - completionDocuments: any[]; - activityPhotos: any[]; invoicesReceipts?: any[]; attendanceSheet?: any; } @@ -899,22 +951,36 @@ export class DealerClaimService { } // Save completion details - await DealerCompletionDetails.upsert({ + const [completionDetails] = await DealerCompletionDetails.upsert({ requestId, activityCompletionDate: completionData.activityCompletionDate, numberOfParticipants: completionData.numberOfParticipants, - closedExpenses: completionData.closedExpenses, totalClosedExpenses: completionData.totalClosedExpenses, - completionDocuments: completionData.completionDocuments, - activityPhotos: completionData.activityPhotos, submittedAt: new Date(), }); - // Update closed expenses in claim details - await DealerClaimDetails.update( - { closedExpenses: completionData.totalClosedExpenses }, - { where: { requestId } } - ); + // Persist individual closed expenses to dealer_completion_expenses + const completionId = (completionDetails as any)?.completionId; + if (completionData.closedExpenses && completionData.closedExpenses.length > 0) { + // Clear existing expenses for this request to avoid duplicates + await DealerCompletionExpense.destroy({ where: { requestId } }); + const expenseRows = completionData.closedExpenses.map((item: any) => ({ + requestId, + completionId, + description: item.description, + amount: item.amount, + })); + await DealerCompletionExpense.bulkCreate(expenseRows); + } + + // Update budget tracking with closed expenses + await ClaimBudgetTracking.upsert({ + requestId, + closedExpenses: completionData.totalClosedExpenses, + closedExpensesSubmittedAt: new Date(), + budgetStatus: BudgetStatus.CLOSED, + currency: 'INR', + }); // Approve step 5 and move to step 6 const level5 = await ApprovalLevel.findOne({ @@ -1016,6 +1082,15 @@ export class DealerClaimService { }); } + // Update budget tracking with blocked amount + await ClaimBudgetTracking.upsert({ + requestId, + ioBlockedAmount: blockedAmount, + ioBlockedAt: new Date(), + budgetStatus: BudgetStatus.BLOCKED, + currency: 'INR', + }); + logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, { ioNumber: ioData.ioNumber, blockedAmount, @@ -1047,6 +1122,11 @@ export class DealerClaimService { throw new Error('Claim details not found'); } + const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } }); + const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); + const internalOrder = await InternalOrder.findOne({ where: { requestId } }); + const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } }); + const request = await WorkflowRequest.findByPk(requestId); if (!request) { throw new Error('Workflow request not found'); @@ -1062,7 +1142,11 @@ export class DealerClaimService { // If invoice data not provided, generate via DMS if (!invoiceData?.eInvoiceNumber) { const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); - const invoiceAmount = invoiceData?.amount || proposalDetails?.totalEstimatedBudget || claimDetails.estimatedBudget || 0; + const invoiceAmount = invoiceData?.amount + || proposalDetails?.totalEstimatedBudget + || budgetTracking?.proposalEstimatedBudget + || budgetTracking?.initialEstimatedBudget + || 0; const invoiceResult = await dmsIntegrationService.generateEInvoice({ requestNumber, @@ -1070,21 +1154,22 @@ export class DealerClaimService { dealerName: claimDetails.dealerName, amount: invoiceAmount, description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`, - ioNumber: claimDetails.ioNumber || undefined, + ioNumber: internalOrder?.ioNumber || undefined, }); if (!invoiceResult.success) { throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`); } - await DealerClaimDetails.update( - { - eInvoiceNumber: invoiceResult.eInvoiceNumber, - eInvoiceDate: invoiceResult.invoiceDate || new Date(), - dmsNumber: invoiceResult.dmsNumber, - }, - { where: { requestId } } - ); + await ClaimInvoice.upsert({ + requestId, + invoiceNumber: invoiceResult.eInvoiceNumber, + invoiceDate: invoiceResult.invoiceDate || new Date(), + dmsNumber: invoiceResult.dmsNumber, + amount: invoiceAmount, + status: 'GENERATED', + description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`, + }); logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, { eInvoiceNumber: invoiceResult.eInvoiceNumber, @@ -1092,14 +1177,15 @@ export class DealerClaimService { }); } else { // Manual entry - just update the fields - await DealerClaimDetails.update( - { - eInvoiceNumber: invoiceData.eInvoiceNumber, - eInvoiceDate: invoiceData.eInvoiceDate || new Date(), - dmsNumber: invoiceData.dmsNumber, - }, - { where: { requestId } } - ); + await ClaimInvoice.upsert({ + requestId, + invoiceNumber: invoiceData.eInvoiceNumber, + invoiceDate: invoiceData.eInvoiceDate || new Date(), + dmsNumber: invoiceData.dmsNumber, + amount: invoiceData.amount, + status: 'UPDATED', + description: invoiceData.description, + }); logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`); } @@ -1168,8 +1254,10 @@ export class DealerClaimService { } // If e-invoice already generated, just auto-approve Step 7 - if (claimDetails.eInvoiceNumber) { - logger.info(`[DealerClaimService] E-Invoice already generated (${claimDetails.eInvoiceNumber}). Auto-approving Step 7 for request ${requestId}`); + const claimInvoice = await ClaimInvoice.findOne({ where: { requestId } }); + + if (claimInvoice?.invoiceNumber) { + logger.info(`[DealerClaimService] E-Invoice already generated (${claimInvoice.invoiceNumber}). Auto-approving Step 7 for request ${requestId}`); // Auto-approve Step 7 await this.approvalService.approveLevel( @@ -1209,7 +1297,11 @@ export class DealerClaimService { throw new Error('Claim details not found'); } - if (!claimDetails.eInvoiceNumber) { + const budgetTracking = await ClaimBudgetTracking.findOne({ where: { requestId } }); + const completionDetails = await DealerCompletionDetails.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'); } @@ -1218,11 +1310,14 @@ export class DealerClaimService { // If credit note data not provided, generate via DMS if (!creditNoteData?.creditNoteNumber) { - const creditNoteAmount = creditNoteData?.creditNoteAmount || claimDetails.closedExpenses || 0; + const creditNoteAmount = creditNoteData?.creditNoteAmount + || budgetTracking?.closedExpenses + || completionDetails?.totalClosedExpenses + || 0; const creditNoteResult = await dmsIntegrationService.generateCreditNote({ requestNumber, - eInvoiceNumber: claimDetails.eInvoiceNumber, + eInvoiceNumber: claimInvoice.invoiceNumber, dealerCode: claimDetails.dealerCode, dealerName: claimDetails.dealerName, amount: creditNoteAmount, @@ -1234,14 +1329,15 @@ export class DealerClaimService { throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`); } - await DealerClaimDetails.update( - { - creditNoteNumber: creditNoteResult.creditNoteNumber, - creditNoteDate: creditNoteResult.creditNoteDate || new Date(), - creditNoteAmount: creditNoteResult.creditNoteAmount, - }, - { where: { requestId } } - ); + await ClaimCreditNote.upsert({ + requestId, + creditNoteNumber: creditNoteResult.creditNoteNumber, + creditNoteDate: creditNoteResult.creditNoteDate || new Date(), + creditNoteAmount: creditNoteResult.creditNoteAmount, + status: 'GENERATED', + 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, @@ -1249,14 +1345,15 @@ export class DealerClaimService { }); } else { // Manual entry - just update the fields - await DealerClaimDetails.update( - { - creditNoteNumber: creditNoteData.creditNoteNumber, - creditNoteDate: creditNoteData.creditNoteDate || new Date(), - creditNoteAmount: creditNoteData.creditNoteAmount, - }, - { where: { requestId } } - ); + await ClaimCreditNote.upsert({ + requestId, + creditNoteNumber: creditNoteData.creditNoteNumber, + creditNoteDate: creditNoteData.creditNoteDate || new Date(), + creditNoteAmount: creditNoteData.creditNoteAmount, + status: 'UPDATED', + reason: creditNoteData?.reason, + description: creditNoteData?.description, + }); logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`); } diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 817b081..cac66ec 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -16,10 +16,83 @@ interface OktaUser { login: string; department?: string; mobilePhone?: string; + [key: string]: any; // Allow any additional profile fields }; } +/** + * Extract full user data from Okta Users API response (centralized extraction) + * This ensures consistent field mapping across all user creation/update operations + */ +function extractOktaUserData(oktaUserResponse: any): SSOUserData | null { + try { + const profile = oktaUserResponse.profile || {}; + + const userData: SSOUserData = { + oktaSub: oktaUserResponse.id || '', + email: profile.email || profile.login || '', + employeeId: profile.employeeID || profile.employeeId || profile.employee_id || undefined, + firstName: profile.firstName || undefined, + lastName: profile.lastName || undefined, + displayName: profile.displayName || undefined, + department: profile.department || undefined, + designation: profile.title || profile.designation || undefined, + phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined, + manager: profile.manager || undefined, // Manager name from Okta + jobTitle: profile.title || undefined, + postalAddress: profile.postalAddress || undefined, + mobilePhone: profile.mobilePhone || undefined, + secondEmail: profile.secondEmail || profile.second_email || undefined, + adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined, + }; + + // Validate required fields + if (!userData.oktaSub || !userData.email) { + return null; + } + + return userData; + } catch (error) { + return null; + } +} + export class UserService { + /** + * Build a consistent user payload for create/update from SSO data. + * @param isUpdate - If true, excludes email from payload (email should never be updated) + */ + private buildUserPayload(ssoData: SSOUserData, existingRole?: string, isUpdate: boolean = false) { + const now = new Date(); + const payload: any = { + oktaSub: ssoData.oktaSub, + employeeId: ssoData.employeeId || null, + firstName: ssoData.firstName || null, + lastName: ssoData.lastName || null, + displayName: ssoData.displayName || null, + department: ssoData.department || null, + designation: ssoData.designation || null, + phone: ssoData.phone || null, + manager: ssoData.manager || null, + jobTitle: ssoData.designation || ssoData.jobTitle || null, + postalAddress: ssoData.postalAddress || null, + mobilePhone: ssoData.mobilePhone || null, + secondEmail: ssoData.secondEmail || null, + adGroups: ssoData.adGroups || null, + lastLogin: now, + updatedAt: now, + isActive: ssoData.isActive ?? true, + role: (ssoData.role as any) || existingRole || 'USER', + }; + + // Only include email for new users (never update email for existing users) + if (!isUpdate) { + payload.email = ssoData.email; + } + + return payload; + } + async createOrUpdateUser(ssoData: SSOUserData): Promise { // Validate required fields if (!ssoData.email || !ssoData.oktaSub) { @@ -36,44 +109,18 @@ export class UserService { } }); - const now = new Date(); - if (existingUser) { - // Update existing user - include oktaSub to ensure it's synced - await existingUser.update({ - email: ssoData.email, - oktaSub: ssoData.oktaSub, - employeeId: ssoData.employeeId || null, // Optional - firstName: ssoData.firstName || null, - lastName: ssoData.lastName || null, - displayName: ssoData.displayName || null, - department: ssoData.department || null, - designation: ssoData.designation || null, - phone: ssoData.phone || null, - // location: (ssoData as any).location || null, // Ignored for now - schema not finalized - lastLogin: now, - updatedAt: now, - isActive: true, // Ensure user is active after SSO login - }); + // Update existing user - DO NOT update email (crucial identifier) + const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true + + await existingUser.update(updatePayload); return existingUser; } else { - // Create new user - oktaSub is required - const newUser = await UserModel.create({ - email: ssoData.email, - oktaSub: ssoData.oktaSub, // Required - employeeId: ssoData.employeeId || null, // Optional - firstName: ssoData.firstName || null, - lastName: ssoData.lastName || null, - displayName: ssoData.displayName || null, - department: ssoData.department || null, - designation: ssoData.designation || null, - phone: ssoData.phone || null, - // location: (ssoData as any).location || null, // Ignored for now - schema not finalized - isActive: true, - role: 'USER', // Default role for new users - lastLogin: now - }); + // Create new user - oktaSub is required, email is included + const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false + + const newUser = await UserModel.create(createPayload); return newUser; } @@ -221,9 +268,10 @@ export class UserService { } /** - * Fetch user from Okta by email + * Fetch user from Okta by email and extract full profile data + * Returns SSOUserData with all fields including manager, jobTitle, etc. */ - async fetchUserFromOktaByEmail(email: string): Promise { + async fetchAndExtractOktaUserByEmail(email: string): Promise { try { const oktaDomain = process.env.OKTA_DOMAIN; const oktaApiToken = process.env.OKTA_API_TOKEN; @@ -232,7 +280,25 @@ export class UserService { return null; } - // Search Okta users by email (exact match) + // Try to fetch by email directly first (more reliable) + try { + const directResponse = await axios.get(`${oktaDomain}/api/v1/users/${encodeURIComponent(email)}`, { + headers: { + 'Authorization': `SSWS ${oktaApiToken}`, + 'Accept': 'application/json' + }, + timeout: 5000, + validateStatus: (status) => status < 500 + }); + + if (directResponse.status === 200 && directResponse.data) { + return extractOktaUserData(directResponse.data); + } + } catch (directError) { + // Fall through to search method + } + + // Fallback: Search Okta users by email const response = await axios.get(`${oktaDomain}/api/v1/users`, { params: { search: `profile.email eq "${email}"`, limit: 1 }, headers: { @@ -242,14 +308,42 @@ export class UserService { timeout: 5000 }); - const users: OktaUser[] = response.data || []; - return users.length > 0 ? users[0] : null; + const users: any[] = response.data || []; + if (users.length > 0) { + return extractOktaUserData(users[0]); + } + + return null; } catch (error: any) { console.error(`Failed to fetch user from Okta by email ${email}:`, error.message); return null; } } + /** + * Fetch user from Okta by email (legacy method, kept for backward compatibility) + * @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction + */ + async fetchUserFromOktaByEmail(email: string): Promise { + const userData = await this.fetchAndExtractOktaUserByEmail(email); + if (!userData) return null; + + // Return in legacy format for backward compatibility + return { + id: userData.oktaSub, + status: 'ACTIVE', + profile: { + email: userData.email, + login: userData.email, + firstName: userData.firstName, + lastName: userData.lastName, + displayName: userData.displayName, + department: userData.department, + mobilePhone: userData.mobilePhone, + } + }; + } + /** * Ensure user exists in database (create if not exists) * Used when tagging users from Okta search results or when only email is provided diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index 444bf35..de63baf 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -10,6 +10,13 @@ export interface SSOUserData { phone?: string; reportingManagerId?: string; manager?: string; // Optional - Manager name from Okta profile + jobTitle?: string; // Detailed title from Okta profile.title + postalAddress?: string; + mobilePhone?: string; + secondEmail?: string; + adGroups?: string[]; + role?: 'USER' | 'MANAGEMENT' | 'ADMIN'; + isActive?: boolean; } export interface SSOConfig {