changes till 12/06/2026

changes till 12/06/2026
main
Manu Krishna 2026-06-12 09:59:03 +05:30
parent da5147a1b8
commit 64cb351271
18 changed files with 15112 additions and 48 deletions

File diff suppressed because it is too large Load Diff

397
GLOBAL_500_ERRORS.txt Normal file
View File

@ -0,0 +1,397 @@
[2026-06-01T06:59:45.525Z] 500 ERROR on PATCH /followups/ca96fb96-a274-452b-b850-c9e20c0c57bd
Error:
Invalid `this.prisma.followup.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:53
171 // Fetch current state before update to detect reassignment
172 const existing = await this.prisma.followup.findUnique({ where: { id } });
173
→ 174 const followup = await this.prisma.followup.update({
where: {
id: "ca96fb96-a274-452b-b850-c9e20c0c57bd"
},
data: {
status: "DONE",
demoPersonName: "Binu",
demoContactDetails: "8568595623",
keyQueries: "",
competitorMention: "",
customerFeedback: "no remarks",
~~~~~~~~~~~~~~~~
requirementDetails: "nothing much",
suggestions: "no",
budget: "55000",
expectedClosingTimeline: "2",
competitorInfo: "no",
staffRemarks: "friendly customer",
customerCommitments: "nothing",
caCsDetails: "no",
? id?: String | StringFieldUpdateOperationsInput,
? date?: DateTime | DateTimeFieldUpdateOperationsInput,
? notes?: String | NullableStringFieldUpdateOperationsInput | Null,
? createdAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? updatedAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? contentType?: String | NullableStringFieldUpdateOperationsInput | Null,
? stage?: followup_stage | Enumfollowup_stageFieldUpdateOperationsInput,
? objections?: String | NullableStringFieldUpdateOperationsInput | Null,
? type?: activity_type | Enumactivity_typeFieldUpdateOperationsInput,
? client?: ClientUpdateOneRequiredWithoutFollowupsNestedInput,
? enquiry?: EnquiryUpdateOneWithoutFollowupsNestedInput,
? opportunity?: OpportunityUpdateOneWithoutActivitiesNestedInput,
? user?: UserUpdateOneRequiredWithoutFollowupsNestedInput
},
include: {
user: true,
client: true
}
})
Unknown argument `customerFeedback`. Available options are marked with ?.
Stack: PrismaClientValidationError:
Invalid `this.prisma.followup.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:53
171 // Fetch current state before update to detect reassignment
172 const existing = await this.prisma.followup.findUnique({ where: { id } });
173
→ 174 const followup = await this.prisma.followup.update({
where: {
id: "ca96fb96-a274-452b-b850-c9e20c0c57bd"
},
data: {
status: "DONE",
demoPersonName: "Binu",
demoContactDetails: "8568595623",
keyQueries: "",
competitorMention: "",
customerFeedback: "no remarks",
~~~~~~~~~~~~~~~~
requirementDetails: "nothing much",
suggestions: "no",
budget: "55000",
expectedClosingTimeline: "2",
competitorInfo: "no",
staffRemarks: "friendly customer",
customerCommitments: "nothing",
caCsDetails: "no",
? id?: String | StringFieldUpdateOperationsInput,
? date?: DateTime | DateTimeFieldUpdateOperationsInput,
? notes?: String | NullableStringFieldUpdateOperationsInput | Null,
? createdAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? updatedAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? contentType?: String | NullableStringFieldUpdateOperationsInput | Null,
? stage?: followup_stage | Enumfollowup_stageFieldUpdateOperationsInput,
? objections?: String | NullableStringFieldUpdateOperationsInput | Null,
? type?: activity_type | Enumactivity_typeFieldUpdateOperationsInput,
? client?: ClientUpdateOneRequiredWithoutFollowupsNestedInput,
? enquiry?: EnquiryUpdateOneWithoutFollowupsNestedInput,
? opportunity?: OpportunityUpdateOneWithoutActivitiesNestedInput,
? user?: UserUpdateOneRequiredWithoutFollowupsNestedInput
},
include: {
user: true,
client: true
}
})
Unknown argument `customerFeedback`. Available options are marked with ?.
at wn (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:29:1363)
at $n.handleRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6958)
at $n.handleAndLogRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6623)
at $n.request (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6307)
at async l (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:130:9633)
at async FollowupsService.update (C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:26)
[2026-06-01T07:00:03.225Z] 500 ERROR on PATCH /followups/ca96fb96-a274-452b-b850-c9e20c0c57bd
Error:
Invalid `this.prisma.followup.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:53
171 // Fetch current state before update to detect reassignment
172 const existing = await this.prisma.followup.findUnique({ where: { id } });
173
→ 174 const followup = await this.prisma.followup.update({
where: {
id: "ca96fb96-a274-452b-b850-c9e20c0c57bd"
},
data: {
status: "DONE",
demoPersonName: "Binu",
demoContactDetails: "8568595623",
keyQueries: "",
competitorMention: "",
customerFeedback: "no remarks",
~~~~~~~~~~~~~~~~
requirementDetails: "nothing much",
suggestions: "no",
budget: "55000",
expectedClosingTimeline: "2",
competitorInfo: "no",
staffRemarks: "friendly customer",
customerCommitments: "nothing",
caCsDetails: "no",
? id?: String | StringFieldUpdateOperationsInput,
? date?: DateTime | DateTimeFieldUpdateOperationsInput,
? notes?: String | NullableStringFieldUpdateOperationsInput | Null,
? createdAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? updatedAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? contentType?: String | NullableStringFieldUpdateOperationsInput | Null,
? stage?: followup_stage | Enumfollowup_stageFieldUpdateOperationsInput,
? objections?: String | NullableStringFieldUpdateOperationsInput | Null,
? type?: activity_type | Enumactivity_typeFieldUpdateOperationsInput,
? client?: ClientUpdateOneRequiredWithoutFollowupsNestedInput,
? enquiry?: EnquiryUpdateOneWithoutFollowupsNestedInput,
? opportunity?: OpportunityUpdateOneWithoutActivitiesNestedInput,
? user?: UserUpdateOneRequiredWithoutFollowupsNestedInput
},
include: {
user: true,
client: true
}
})
Unknown argument `customerFeedback`. Available options are marked with ?.
Stack: PrismaClientValidationError:
Invalid `this.prisma.followup.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:53
171 // Fetch current state before update to detect reassignment
172 const existing = await this.prisma.followup.findUnique({ where: { id } });
173
→ 174 const followup = await this.prisma.followup.update({
where: {
id: "ca96fb96-a274-452b-b850-c9e20c0c57bd"
},
data: {
status: "DONE",
demoPersonName: "Binu",
demoContactDetails: "8568595623",
keyQueries: "",
competitorMention: "",
customerFeedback: "no remarks",
~~~~~~~~~~~~~~~~
requirementDetails: "nothing much",
suggestions: "no",
budget: "55000",
expectedClosingTimeline: "2",
competitorInfo: "no",
staffRemarks: "friendly customer",
customerCommitments: "nothing",
caCsDetails: "no",
? id?: String | StringFieldUpdateOperationsInput,
? date?: DateTime | DateTimeFieldUpdateOperationsInput,
? notes?: String | NullableStringFieldUpdateOperationsInput | Null,
? createdAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? updatedAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? contentType?: String | NullableStringFieldUpdateOperationsInput | Null,
? stage?: followup_stage | Enumfollowup_stageFieldUpdateOperationsInput,
? objections?: String | NullableStringFieldUpdateOperationsInput | Null,
? type?: activity_type | Enumactivity_typeFieldUpdateOperationsInput,
? client?: ClientUpdateOneRequiredWithoutFollowupsNestedInput,
? enquiry?: EnquiryUpdateOneWithoutFollowupsNestedInput,
? opportunity?: OpportunityUpdateOneWithoutActivitiesNestedInput,
? user?: UserUpdateOneRequiredWithoutFollowupsNestedInput
},
include: {
user: true,
client: true
}
})
Unknown argument `customerFeedback`. Available options are marked with ?.
at wn (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:29:1363)
at $n.handleRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6958)
at $n.handleAndLogRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6623)
at $n.request (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6307)
at async l (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:130:9633)
at async FollowupsService.update (C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:26)
[2026-06-01T07:01:05.920Z] 500 ERROR on PATCH /followups/ca96fb96-a274-452b-b850-c9e20c0c57bd
Error:
Invalid `this.prisma.followup.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:53
171 // Fetch current state before update to detect reassignment
172 const existing = await this.prisma.followup.findUnique({ where: { id } });
173
→ 174 const followup = await this.prisma.followup.update({
where: {
id: "ca96fb96-a274-452b-b850-c9e20c0c57bd"
},
data: {
status: "DONE",
demoPersonName: "Binu",
demoContactDetails: "8568595623",
keyQueries: "",
competitorMention: "",
customerFeedback: "no remarks",
~~~~~~~~~~~~~~~~
requirementDetails: "nothing much",
suggestions: "no",
budget: "55000",
expectedClosingTimeline: "2",
competitorInfo: "no",
staffRemarks: "friendly customer",
customerCommitments: "nothing",
caCsDetails: "no",
? id?: String | StringFieldUpdateOperationsInput,
? date?: DateTime | DateTimeFieldUpdateOperationsInput,
? notes?: String | NullableStringFieldUpdateOperationsInput | Null,
? createdAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? updatedAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? contentType?: String | NullableStringFieldUpdateOperationsInput | Null,
? stage?: followup_stage | Enumfollowup_stageFieldUpdateOperationsInput,
? objections?: String | NullableStringFieldUpdateOperationsInput | Null,
? type?: activity_type | Enumactivity_typeFieldUpdateOperationsInput,
? client?: ClientUpdateOneRequiredWithoutFollowupsNestedInput,
? enquiry?: EnquiryUpdateOneWithoutFollowupsNestedInput,
? opportunity?: OpportunityUpdateOneWithoutActivitiesNestedInput,
? user?: UserUpdateOneRequiredWithoutFollowupsNestedInput
},
include: {
user: true,
client: true
}
})
Unknown argument `customerFeedback`. Available options are marked with ?.
Stack: PrismaClientValidationError:
Invalid `this.prisma.followup.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:53
171 // Fetch current state before update to detect reassignment
172 const existing = await this.prisma.followup.findUnique({ where: { id } });
173
→ 174 const followup = await this.prisma.followup.update({
where: {
id: "ca96fb96-a274-452b-b850-c9e20c0c57bd"
},
data: {
status: "DONE",
demoPersonName: "Binu",
demoContactDetails: "8568595623",
keyQueries: "",
competitorMention: "",
customerFeedback: "no remarks",
~~~~~~~~~~~~~~~~
requirementDetails: "nothing much",
suggestions: "no",
budget: "55000",
expectedClosingTimeline: "2",
competitorInfo: "no",
staffRemarks: "friendly customer",
customerCommitments: "nothing",
caCsDetails: "no",
? id?: String | StringFieldUpdateOperationsInput,
? date?: DateTime | DateTimeFieldUpdateOperationsInput,
? notes?: String | NullableStringFieldUpdateOperationsInput | Null,
? createdAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? updatedAt?: DateTime | DateTimeFieldUpdateOperationsInput,
? contentType?: String | NullableStringFieldUpdateOperationsInput | Null,
? stage?: followup_stage | Enumfollowup_stageFieldUpdateOperationsInput,
? objections?: String | NullableStringFieldUpdateOperationsInput | Null,
? type?: activity_type | Enumactivity_typeFieldUpdateOperationsInput,
? client?: ClientUpdateOneRequiredWithoutFollowupsNestedInput,
? enquiry?: EnquiryUpdateOneWithoutFollowupsNestedInput,
? opportunity?: OpportunityUpdateOneWithoutActivitiesNestedInput,
? user?: UserUpdateOneRequiredWithoutFollowupsNestedInput
},
include: {
user: true,
client: true
}
})
Unknown argument `customerFeedback`. Available options are marked with ?.
at wn (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:29:1363)
at $n.handleRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6958)
at $n.handleAndLogRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6623)
at $n.request (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6307)
at async l (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:130:9633)
at async FollowupsService.update (C:\ignosidev\Igcrm\apps\api\src\followups\followups.service.ts:174:26)
[2026-06-09T06:21:07.186Z] 500 ERROR on PATCH /products/02cb5871-795c-419f-99f8-6710f77274e9
Error:
Invalid `this.prisma.product.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\products\products.service.ts:45:32
42 await this.prisma.productAddon.deleteMany({ where: { productId: id } });
43 }
44
→ 45 return this.prisma.product.update({
where: {
id: "02cb5871-795c-419f-99f8-6710f77274e9"
},
data: {
name: "Nidhi",
description: "Nidhi is a software for Nidhi companies",
price: 35000,
maxDiscountPercentage: 15,
schemes: {
create: [
{
name: "with branch",
price: 45000,
features: "",
brochureUrl: null,
maxDiscountPercentage: 100,
productId: "02cb5871-795c-419f-99f8-6710f77274e9",
createdAt: "2026-06-04T05:31:55.630Z",
updatedAt: "2026-06-04T05:31:55.630Z"
}
]
},
addons: undefined
},
include: {
schemes: true,
addons: true
}
})
Unknown argument `productId`. Available options are marked with ?.
Stack: PrismaClientValidationError:
Invalid `this.prisma.product.update()` invocation in
C:\ignosidev\Igcrm\apps\api\src\products\products.service.ts:45:32
42 await this.prisma.productAddon.deleteMany({ where: { productId: id } });
43 }
44
→ 45 return this.prisma.product.update({
where: {
id: "02cb5871-795c-419f-99f8-6710f77274e9"
},
data: {
name: "Nidhi",
description: "Nidhi is a software for Nidhi companies",
price: 35000,
maxDiscountPercentage: 15,
schemes: {
create: [
{
name: "with branch",
price: 45000,
features: "",
brochureUrl: null,
maxDiscountPercentage: 100,
productId: "02cb5871-795c-419f-99f8-6710f77274e9",
createdAt: "2026-06-04T05:31:55.630Z",
updatedAt: "2026-06-04T05:31:55.630Z"
}
]
},
addons: undefined
},
include: {
schemes: true,
addons: true
}
})
Unknown argument `productId`. Available options are marked with ?.
at wn (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:29:1363)
at $n.handleRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6958)
at $n.handleAndLogRequestError (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6623)
at $n.request (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:121:6307)
at async l (C:\ignosidev\Igcrm\apps\api\node_modules\@prisma\client\runtime\library.js:130:9633)

23
check_followup.js Normal file
View File

@ -0,0 +1,23 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const followupId = "ca96fb96-a274-452b-b850-c9e20c0c57bd";
const followup = await prisma.followup.findUnique({
where: { id: followupId },
include: { opportunity: true, client: true }
});
console.log("FOLLOWUP:", JSON.stringify(followup, null, 2));
if (followup && followup.clientId) {
const opps = await prisma.opportunity.findMany({
where: { clientId: followup.clientId }
});
console.log("CLIENT OPPORTUNITIES:", JSON.stringify(opps, null, 2));
}
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -121,6 +121,7 @@ model Followup {
demoPersonName String?
keyQueries String?
objections String?
suggestions String?
opportunityId String?
type activity_type @default(FOLLOWUP)
client Client @relation(fields: [clientId], references: [id], map: "Followup_clientId_fkey")
@ -189,6 +190,8 @@ model Opportunity {
clientId String
assignedTo String
expectedCloseDate DateTime?
closingProbability Float?
expectedClosingTimeframe String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
competitorMention String?
@ -200,6 +203,12 @@ model Opportunity {
objections String?
paymentMode String?
specialRate Float?
selectedSchemeId String?
addonsSelected String? @db.LongText
discountValue Float? @default(0)
isAmcMarked Boolean? @default(false)
amcPercentage Float? @default(0)
extraCharges Float? @default(0)
creatorId String?
closingOwnerId String?
demoOwnerId String?
@ -259,13 +268,68 @@ model Product {
description String?
price Float
imageUrl String?
brochureUrl String?
maxDiscountPercentage Float? @default(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enquiries Enquiry[] @relation("enquiry_products")
schemes ProductScheme[]
addons ProductAddon[]
userDiscounts UserProductDiscount[]
@@map("product")
}
model ProductScheme {
id String @id @default(uuid())
productId String
name String
price Float
features String? @db.Text
brochureUrl String?
maxDiscountPercentage Float? @default(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
userDiscounts UserProductDiscount[]
@@index([productId])
@@map("product_scheme")
}
model UserProductDiscount {
id String @id @default(uuid())
userId String
productId String
schemeId String? // null = product-level cap; set = scheme-specific cap
maxDiscountPercentage Float @default(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
scheme ProductScheme? @relation(fields: [schemeId], references: [id], onDelete: Cascade)
@@unique([userId, productId, schemeId])
@@index([userId])
@@index([productId])
@@index([schemeId])
@@map("user_product_discount")
}
model ProductAddon {
id String @id @default(uuid())
productId String
name String // e.g. "Cloud Backup", "Custom SMTP"
price Float // price of this addon
description String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([productId])
@@map("product_addon")
}
model Quote {
id String @id @default(uuid())
enquiryId String?
@ -382,6 +446,7 @@ model User {
manager User? @relation("userTouser", fields: [managerId], references: [id], map: "User_managerId_fkey")
subordinates User[] @relation("userTouser")
permissions String? @db.LongText
productDiscounts UserProductDiscount[]
@@index([managerId], map: "User_managerId_fkey")
@@map("user")
@ -495,4 +560,9 @@ enum activity_type {
FOLLOWUP
DEMO
QUOTE
SECOND_DEMO
SECOND_QUOTE
MANAGER_HELP
PROBABILITY_UPDATE
TIMEFRAME_UPDATE
}

View File

@ -12,6 +12,133 @@ export class FollowupsService {
private whatsappService: WhatsappService
) { }
// Auto-transition pipeline opportunity stage forward
private async autoTransitionStage(opportunityId: string, newStage: 'QUALIFIED' | 'POTENTIAL' | 'SALES') {
try {
const current = await this.prisma.opportunity.findUnique({
where: { id: opportunityId },
include: { client: true }
});
if (!current) return;
const stagesOrder = { LEAD: 1, QUALIFIED: 2, POTENTIAL: 3, SALES: 4, LOST: 0 };
const currentPriority = stagesOrder[current.stage] || 0;
const newPriority = stagesOrder[newStage] || 0;
// Only transition forward
if (newPriority > currentPriority) {
const updated = await this.prisma.opportunity.update({
where: { id: opportunityId },
data: { stage: newStage, updatedAt: new Date() },
include: { client: true }
});
console.log(`[AUTO-STAGE] Transitioned opportunity ${opportunityId} from ${current.stage} to ${newStage}`);
try {
await this.whatsappService.sendPipelineUpdate(
updated.client.phone,
updated.client.name,
current.stage,
updated.stage
);
} catch (err) {
console.error('Failed to send pipeline WhatsApp on auto-transition:', err.message);
}
}
} catch (err) {
console.error('Error during auto-transition of opportunity stage:', err);
}
}
private async evaluateStageTransitions(followup: any) {
let opportunityIds: string[] = [];
if (followup.opportunityId) {
opportunityIds.push(followup.opportunityId);
} else if (followup.clientId) {
// Fallback: If opportunityId is null, resolve all open opportunities of the client
try {
const clientOpps = await this.prisma.opportunity.findMany({
where: {
clientId: followup.clientId,
stage: { in: ['LEAD', 'QUALIFIED', 'POTENTIAL'] }
},
select: { id: true }
});
opportunityIds = clientOpps.map(o => o.id);
} catch (err) {
console.error('Failed to look up client opportunities for stage transition fallback:', err);
}
}
if (opportunityIds.length === 0) return;
const type = followup.type;
try {
for (const opportunityId of opportunityIds) {
if (['QUOTE_SEND', 'QUOTE', 'QUOTE_REQUEST', 'SECOND_QUOTE'].includes(type)) {
// Count previous quotes and activities of this type
const prevQuotesCount = await this.prisma.quote.count({
where: { opportunityId }
});
const prevQuoteActivities = await this.prisma.followup.count({
where: {
opportunityId,
id: { not: followup.id },
type: { in: ['QUOTE_SEND', 'QUOTE', 'QUOTE_REQUEST', 'SECOND_QUOTE'] }
}
});
if (prevQuotesCount + prevQuoteActivities >= 1) {
await this.autoTransitionStage(opportunityId, 'SALES');
} else {
await this.autoTransitionStage(opportunityId, 'QUALIFIED');
}
} else if (['DEMO_SCHEDULED', 'DEMO_COMPLETED', 'DEMO', 'SECOND_DEMO'].includes(type)) {
// Count previous demos
const prevDemoActivities = await this.prisma.followup.count({
where: {
opportunityId,
id: { not: followup.id },
type: { in: ['DEMO_SCHEDULED', 'DEMO_COMPLETED', 'DEMO', 'SECOND_DEMO'] }
}
});
if (prevDemoActivities >= 1) {
await this.autoTransitionStage(opportunityId, 'SALES');
} else {
await this.autoTransitionStage(opportunityId, 'POTENTIAL');
}
} else if (['VISIT_SCHEDULED', 'VISIT_COMPLETED'].includes(type)) {
await this.autoTransitionStage(opportunityId, 'SALES');
}
// If it's a mutation activity marked as DONE
if (followup.status === 'DONE') {
if (type === 'TIMEFRAME_UPDATE' && followup.expectedClosingTimeframe) {
await this.prisma.opportunity.update({
where: { id: opportunityId },
data: { expectedClosingTimeframe: followup.expectedClosingTimeframe }
});
}
if (type === 'PROBABILITY_UPDATE' && followup.notes) {
const parsedProb = parseFloat(followup.notes);
if (!isNaN(parsedProb)) {
await this.prisma.opportunity.update({
where: { id: opportunityId },
data: { closingProbability: parsedProb }
});
}
}
}
}
} catch (err) {
console.error('Error in evaluateStageTransitions:', err);
}
}
async create(createFollowupDto: CreateFollowupDto) {
// Map description to notes if provided, and exclude 'time' which is only used by the frontend
const { description, time, ...rest } = createFollowupDto as any;
@ -25,29 +152,30 @@ export class FollowupsService {
include: { client: true }
});
// Trigger WhatsApp followup reminder to client
// Trigger WhatsApp followup reminder — fire-and-forget (non-blocking)
if (followup.client?.phone) {
try {
await this.whatsappService.sendFollowupReminder(
this.whatsappService.sendFollowupReminder(
followup.client.phone,
followup.client.name,
new Date(followup.date).toLocaleString()
);
} catch (err) {
console.error('Failed to send followup WhatsApp:', err.message);
}
).catch(err => console.error('Failed to send followup WhatsApp:', err.message));
}
// Send Alert to Assigned User if different from creator
// Send Alert to Assigned User — non-blocking
if (data.userId) {
await this.notifications.create(
this.notifications.create(
data.userId,
'New Follow-up Assigned 📅',
`You have been assigned a new follow-up task for client. Deadline: ${new Date(data.date).toLocaleString()}`,
'FOLLOWUP_ASSIGNED'
);
).catch(err => console.error('Failed to send notification:', err.message));
}
// Evaluate auto-stage transitions — fire-and-forget (non-blocking)
this.evaluateStageTransitions(followup).catch(err =>
console.error('Error in evaluateStageTransitions (non-blocking):', err)
);
return followup;
}
@ -81,9 +209,48 @@ export class FollowupsService {
// Fetch current state before update to detect reassignment
const existing = await this.prisma.followup.findUnique({ where: { id } });
// Extract extra fields that don't exist in Prisma schema to avoid database errors
const schemaFields = [
'id', 'clientId', 'enquiryId', 'userId', 'date', 'notes', 'status',
'createdAt', 'updatedAt', 'contentType', 'stage', 'competitorMention',
'demoContactDetails', 'demoPersonName', 'keyQueries', 'objections',
'opportunityId', 'type'
];
const extraData: Record<string, any> = {};
const prismaData: Record<string, any> = {};
Object.keys(updateFollowupDto).forEach(key => {
if (schemaFields.includes(key)) {
prismaData[key] = updateFollowupDto[key];
} else {
extraData[key] = updateFollowupDto[key];
}
});
// If there are extra feedback fields, format them nicely and append to 'notes'
if (Object.keys(extraData).length > 0) {
const feedbackSummary = Object.entries(extraData)
.filter(([_, val]) => val !== undefined && val !== null && val !== '')
.map(([key, val]) => {
const label = key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase());
return `${label}: ${val}`;
})
.join('\n');
if (feedbackSummary) {
const originalNotes = existing?.notes || '';
prismaData.notes = originalNotes
? `${originalNotes}\n\n[DEMO FEEDBACK LOG]\n${feedbackSummary}`
: `[DEMO FEEDBACK LOG]\n${feedbackSummary}`;
}
}
const followup = await this.prisma.followup.update({
where: { id },
data: updateFollowupDto,
data: prismaData,
include: { user: true, client: true }
});
@ -131,6 +298,11 @@ export class FollowupsService {
});
}
// Evaluate auto-stage transitions — only when explicitly marking DONE
if (updateFollowupDto.status === 'DONE') {
await this.evaluateStageTransitions(followup);
}
return followup;
}

View File

@ -81,4 +81,28 @@ export class CreateOpportunityDto {
@IsOptional()
@IsString()
expectedClosingTimeframe?: string;
@IsOptional()
@IsString()
selectedSchemeId?: string;
@IsOptional()
@IsString()
addonsSelected?: string;
@IsOptional()
@IsNumber()
discountValue?: number;
@IsOptional()
@IsBoolean()
isAmcMarked?: boolean;
@IsOptional()
@IsNumber()
amcPercentage?: number;
@IsOptional()
@IsNumber()
extraCharges?: number;
}

View File

@ -15,8 +15,96 @@ export class OpportunitiesService {
private readonly whatsappService: WhatsappService
) { }
private async validateDiscount(
title: string,
assignedTo: string,
discountValue: number,
selectedSchemeId?: string | null
) {
if (!discountValue || discountValue <= 0) return;
// 1. Find product by name
const product = await this.prisma.product.findFirst({
where: { name: title },
include: { schemes: true }
});
if (!product) return;
// 2. Determine base price and scheme ID
let basePrice = product.price;
let schemeId: string | null = null;
let scheme: any = null;
if (selectedSchemeId && selectedSchemeId !== 'base') {
scheme = product.schemes.find(s => s.id === selectedSchemeId);
if (scheme) {
basePrice = scheme.price;
schemeId = scheme.id;
}
}
if (basePrice <= 0) return;
// 3. Find applicable cap
let allowedPercentage: number | null = null;
// A. User-Scheme cap
if (schemeId && assignedTo) {
const userSchemeCap = await this.prisma.userProductDiscount.findFirst({
where: { userId: assignedTo, productId: product.id, schemeId }
});
if (userSchemeCap) {
allowedPercentage = userSchemeCap.maxDiscountPercentage;
}
}
// B. User-Product cap
if (allowedPercentage === null && assignedTo) {
const userProductCap = await this.prisma.userProductDiscount.findFirst({
where: { userId: assignedTo, productId: product.id, schemeId: null }
});
if (userProductCap) {
allowedPercentage = userProductCap.maxDiscountPercentage;
}
}
// C. Scheme fallback cap
if (allowedPercentage === null && scheme) {
allowedPercentage = scheme.maxDiscountPercentage;
}
// D. Product fallback cap
if (allowedPercentage === null) {
allowedPercentage = product.maxDiscountPercentage;
}
// E. Ultimate default if everything is null
if (allowedPercentage === null) {
allowedPercentage = 100;
}
// 4. Calculate applied discount percentage
const appliedPercentage = (discountValue / basePrice) * 100;
// Check if limit exceeded (adding a small epsilon for floating point issues)
if (appliedPercentage > allowedPercentage + 0.001) {
const maxAllowedValue = (basePrice * allowedPercentage) / 100;
throw new BadRequestException(
`Applied discount of ₹${discountValue.toLocaleString()} (${appliedPercentage.toFixed(1)}%) exceeds the maximum allowed discount of ${allowedPercentage}% (₹${maxAllowedValue.toLocaleString()}) for this product.`
);
}
}
async create(createOpportunityDto: CreateOpportunityDto) {
try {
await this.validateDiscount(
createOpportunityDto.title,
createOpportunityDto.assignedTo,
createOpportunityDto.discountValue ?? 0,
createOpportunityDto.selectedSchemeId
);
return await this.prisma.opportunity.create({
data: {
id: uuidv4(),
@ -32,6 +120,12 @@ export class OpportunitiesService {
creatorId: createOpportunityDto.creatorId,
closingProbability: createOpportunityDto.closingProbability ? Number(createOpportunityDto.closingProbability) : 0,
expectedClosingTimeframe: createOpportunityDto.expectedClosingTimeframe || null,
selectedSchemeId: createOpportunityDto.selectedSchemeId || null,
addonsSelected: createOpportunityDto.addonsSelected || null,
discountValue: createOpportunityDto.discountValue !== undefined ? Number(createOpportunityDto.discountValue) : 0,
isAmcMarked: !!createOpportunityDto.isAmcMarked,
amcPercentage: createOpportunityDto.amcPercentage !== undefined ? Number(createOpportunityDto.amcPercentage) : 0,
extraCharges: createOpportunityDto.extraCharges !== undefined ? Number(createOpportunityDto.extraCharges) : 0,
updatedAt: new Date(),
},
});
@ -48,6 +142,7 @@ export class OpportunitiesService {
include: {
client: true,
user: true,
activities: true,
},
orderBy: { updatedAt: 'desc' }
});
@ -63,6 +158,7 @@ export class OpportunitiesService {
include: {
client: true,
user: true,
activities: true,
},
orderBy: { updatedAt: 'desc' }
});
@ -74,6 +170,7 @@ export class OpportunitiesService {
include: {
client: true,
user: true,
activities: true,
},
});
if (!opp) throw new NotFoundException('Opportunity not found');
@ -86,6 +183,13 @@ export class OpportunitiesService {
const current = await this.prisma.opportunity.findUnique({ where: { id } });
if (!current) throw new NotFoundException('Opportunity not found');
const title = updateOpportunityDto.title !== undefined ? updateOpportunityDto.title : current.title;
const assignedTo = updateOpportunityDto.assignedTo !== undefined ? updateOpportunityDto.assignedTo : current.assignedTo;
const discountValue = updateOpportunityDto.discountValue !== undefined ? Number(updateOpportunityDto.discountValue) : Number(current.discountValue || 0);
const selectedSchemeId = updateOpportunityDto.selectedSchemeId !== undefined ? updateOpportunityDto.selectedSchemeId : current.selectedSchemeId;
await this.validateDiscount(title, assignedTo, discountValue, selectedSchemeId);
const newStage = updateOpportunityDto.stage || current.stage;
// Validation Logic for Demo
@ -126,6 +230,10 @@ export class OpportunitiesService {
if (data.value) data.value = Number(data.value);
if (data.specialRate) data.specialRate = Number(data.specialRate);
if (data.discountValue !== undefined) data.discountValue = Number(data.discountValue);
if (data.amcPercentage !== undefined) data.amcPercentage = Number(data.amcPercentage);
if (data.extraCharges !== undefined) data.extraCharges = Number(data.extraCharges);
if (data.isAmcMarked !== undefined) data.isAmcMarked = !!data.isAmcMarked;
// Fix foreign key constraint errors for empty strings
if (data.creatorId === '') data.creatorId = null;

View File

@ -1,4 +1,41 @@
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
import { IsNotEmpty, IsNumber, IsOptional, IsString, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateProductSchemeDto {
@IsString()
@IsNotEmpty()
name: string;
@IsNumber()
@IsNotEmpty()
price: number;
@IsString()
@IsOptional()
features?: string;
@IsString()
@IsOptional()
brochureUrl?: string;
@IsNumber()
@IsOptional()
maxDiscountPercentage?: number;
}
export class CreateProductAddonDto {
@IsString()
@IsNotEmpty()
name: string;
@IsNumber()
@IsNotEmpty()
price: number;
@IsString()
@IsOptional()
description?: string;
}
export class CreateProductDto {
@IsString()
@ -16,4 +53,24 @@ export class CreateProductDto {
@IsString()
@IsOptional()
imageUrl?: string;
@IsString()
@IsOptional()
brochureUrl?: string;
@IsNumber()
@IsOptional()
maxDiscountPercentage?: number;
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateProductSchemeDto)
schemes?: CreateProductSchemeDto[];
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateProductAddonDto)
addons?: CreateProductAddonDto[];
}

View File

@ -0,0 +1,16 @@
import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpsertUserProductDiscountDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsOptional()
schemeId?: string; // null / omit = product-level cap
@IsNumber()
@Min(0)
@Max(100)
maxDiscountPercentage: number;
}

View File

@ -1,9 +1,21 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { UpsertUserProductDiscountDto } from './dto/user-product-discount.dto';
@Controller('products')
@UseGuards(AuthGuard('jwt'))
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@ -31,4 +43,32 @@ export class ProductsController {
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
// ─── Per-User Discount Cap Endpoints ─────────────────────────────────────
/** GET /products/:productId/user-discounts — list all user caps for this product */
@Get(':productId/user-discounts')
getUserDiscounts(@Param('productId') productId: string) {
return this.productsService.getUserDiscounts(productId);
}
/** POST /products/:productId/user-discounts — upsert a user cap */
@Post(':productId/user-discounts')
upsertUserDiscount(
@Param('productId') productId: string,
@Body() dto: UpsertUserProductDiscountDto,
) {
return this.productsService.upsertUserDiscount(
productId,
dto.userId,
dto.maxDiscountPercentage,
dto.schemeId ?? null,
);
}
/** DELETE /products/:productId/user-discounts/:discountId — remove a user cap */
@Delete(':productId/user-discounts/:discountId')
removeUserDiscount(@Param('discountId') discountId: string) {
return this.productsService.removeUserDiscount(discountId);
}
}

View File

@ -7,32 +7,112 @@ import { UpdateProductDto } from './dto/update-product.dto';
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
create(createProductDto: CreateProductDto) {
async create(createProductDto: CreateProductDto) {
const { schemes, addons, ...productData } = createProductDto;
return this.prisma.product.create({
data: createProductDto,
data: {
...productData,
schemes: schemes && schemes.length > 0 ? { create: schemes } : undefined,
addons: addons && addons.length > 0 ? { create: addons } : undefined,
},
include: { schemes: true, addons: true },
});
}
findAll() {
return this.prisma.product.findMany();
return this.prisma.product.findMany({
include: { schemes: true, addons: true },
});
}
findOne(id: string) {
return this.prisma.product.findUnique({
where: { id },
include: { schemes: true, addons: true },
});
}
update(id: string, updateProductDto: UpdateProductDto) {
async update(id: string, updateProductDto: UpdateProductDto) {
const { schemes, addons, ...productData } = updateProductDto;
if (schemes) {
await this.prisma.productScheme.deleteMany({ where: { productId: id } });
}
if (addons) {
await this.prisma.productAddon.deleteMany({ where: { productId: id } });
}
return this.prisma.product.update({
where: { id },
data: updateProductDto,
data: {
...productData,
schemes: schemes && schemes.length > 0 ? { create: schemes } : undefined,
addons: addons && addons.length > 0 ? { create: addons } : undefined,
},
include: { schemes: true, addons: true },
});
}
remove(id: string) {
return this.prisma.product.delete({
where: { id },
return this.prisma.product.delete({ where: { id } });
}
// ─── Per-User Discount Cap Methods ───────────────────────────────────────
/** Get all user discount caps for a product (includes scheme-level caps too) */
async getUserDiscounts(productId: string) {
return this.prisma.userProductDiscount.findMany({
where: { productId },
include: {
user: { select: { id: true, name: true, email: true, role: true } },
scheme: { select: { id: true, name: true, price: true } },
},
orderBy: { createdAt: 'asc' },
});
}
/**
* Create or update a discount cap for a specific user on this product.
* Pass schemeId to cap at the scheme/variation level; omit/null for product-level cap.
*/
async upsertUserDiscount(
productId: string,
userId: string,
maxDiscountPercentage: number,
schemeId: string | null,
) {
// Find existing record manually to avoid Prisma null composite-key issue
const existing = await this.prisma.userProductDiscount.findFirst({
where: { userId, productId, schemeId: schemeId ?? null },
});
if (existing) {
return this.prisma.userProductDiscount.update({
where: { id: existing.id },
data: { maxDiscountPercentage },
include: {
user: { select: { id: true, name: true, email: true, role: true } },
scheme: { select: { id: true, name: true, price: true } },
},
});
}
return this.prisma.userProductDiscount.create({
data: {
userId,
productId,
schemeId: schemeId ?? null,
maxDiscountPercentage,
},
include: {
user: { select: { id: true, name: true, email: true, role: true } },
scheme: { select: { id: true, name: true, price: true } },
},
});
}
/** Delete a specific user discount cap by its record ID */
async removeUserDiscount(discountId: string) {
return this.prisma.userProductDiscount.delete({ where: { id: discountId } });
}
}

View File

@ -11,6 +11,45 @@ export class QuotesService {
private readonly whatsappService: WhatsappService
) { }
// Auto-transition pipeline opportunity stage forward
private async autoTransitionStage(opportunityId: string, newStage: 'QUALIFIED' | 'POTENTIAL' | 'SALES') {
try {
const current = await this.prisma.opportunity.findUnique({
where: { id: opportunityId },
include: { client: true }
});
if (!current) return;
const stagesOrder = { LEAD: 1, QUALIFIED: 2, POTENTIAL: 3, SALES: 4, LOST: 0 };
const currentPriority = stagesOrder[current.stage] || 0;
const newPriority = stagesOrder[newStage] || 0;
// Only transition forward
if (newPriority > currentPriority) {
const updated = await this.prisma.opportunity.update({
where: { id: opportunityId },
data: { stage: newStage, updatedAt: new Date() },
include: { client: true }
});
console.log(`[AUTO-STAGE] Transitioned opportunity ${opportunityId} from ${current.stage} to ${newStage}`);
try {
await this.whatsappService.sendPipelineUpdate(
updated.client.phone,
updated.client.name,
current.stage,
updated.stage
);
} catch (err) {
console.error('Failed to send pipeline WhatsApp on auto-transition:', err.message);
}
}
} catch (err) {
console.error('Error during auto-transition of opportunity stage:', err);
}
}
async create(createQuoteDto: CreateQuoteDto) {
const quote = await this.prisma.quote.create({
data: {
@ -49,6 +88,29 @@ export class QuotesService {
console.error('Failed to send quote creation WhatsApp:', err.message);
}
// Trigger auto-stage transition if linked to an opportunity
if (quote.opportunityId) {
try {
// Count previous quotes for this opportunity (excluding this one)
const previousQuotesCount = await this.prisma.quote.count({
where: {
opportunityId: quote.opportunityId,
id: { not: quote.id }
}
});
if (previousQuotesCount >= 1) {
// This is a second/subsequent quote
await this.autoTransitionStage(quote.opportunityId, 'SALES');
} else {
// First quote
await this.autoTransitionStage(quote.opportunityId, 'QUALIFIED');
}
} catch (err) {
console.error('Failed auto-stage transition on quote creation:', err);
}
}
return quote;
}
@ -142,6 +204,11 @@ export class QuotesService {
throw new BadRequestException((result as any).error || 'Failed to send WhatsApp message');
}
// If manual send succeeds and opportunity exists, ensure it is at least QUALIFIED
if (quote.opportunityId) {
await this.autoTransitionStage(quote.opportunityId, 'QUALIFIED');
}
return result;
}

View File

@ -62,7 +62,8 @@ export class WhatsappService {
const cid = process.env.WHATSAPP_CID;
if (!url) {
throw new Error('WHATSAPP_API_URL is not configured in .env');
this.logger.warn('WHATSAPP_API_URL is not configured. Skipping WhatsApp send.');
return { success: false, error: 'WHATSAPP_API_URL not configured' };
}
const payload = {
@ -98,10 +99,16 @@ export class WhatsappService {
this.logger.log(`Attempting to send WhatsApp via POST: ${url}`);
this.logger.log(`Payload: ${JSON.stringify(payload)}`);
// Add 8-second timeout via AbortController to prevent indefinite hanging
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url, {
method: 'POST',
body: formData
body: formData,
signal: controller.signal
});
clearTimeout(timeoutId);
const resultText = await response.text();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.