parent
da5147a1b8
commit
64cb351271
14003
API_REQUEST_LOG.txt
14003
API_REQUEST_LOG.txt
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
|
||||
|
|
@ -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());
|
||||
|
|
@ -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?
|
||||
|
|
@ -254,18 +263,73 @@ model PerformanceScore {
|
|||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
description String?
|
||||
price Float
|
||||
imageUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
enquiries Enquiry[] @relation("enquiry_products")
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
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?
|
||||
|
|
@ -353,15 +417,15 @@ model SystemConfig {
|
|||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique(map: "User_email_key")
|
||||
id String @id @default(uuid())
|
||||
email String @unique(map: "User_email_key")
|
||||
password String
|
||||
name String?
|
||||
role user_role @default(TELESALES_EXECUTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
role user_role @default(TELESALES_EXECUTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
managerId String?
|
||||
status user_status @default(APPROVED)
|
||||
status user_status @default(APPROVED)
|
||||
attendance Attendance[]
|
||||
clients Client[]
|
||||
enquiries Enquiry[]
|
||||
|
|
@ -371,17 +435,18 @@ model User {
|
|||
locations Location[]
|
||||
meetings Meeting[]
|
||||
notifications Notification[]
|
||||
opportunities Opportunity[] @relation("opportunity_assignedToTouser")
|
||||
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
|
||||
createdOpportunities Opportunity[] @relation("opportunity_creatorIdTouser")
|
||||
demoOpportunities Opportunity[] @relation("opportunity_demoOwnerIdTouser")
|
||||
opportunities Opportunity[] @relation("opportunity_assignedToTouser")
|
||||
closedOpportunities Opportunity[] @relation("opportunity_closingOwnerIdTouser")
|
||||
createdOpportunities Opportunity[] @relation("opportunity_creatorIdTouser")
|
||||
demoOpportunities Opportunity[] @relation("opportunity_demoOwnerIdTouser")
|
||||
performanceScores PerformanceScore[]
|
||||
quotes Quote[]
|
||||
strategicActivities StrategicActivity[]
|
||||
targets Target[]
|
||||
manager User? @relation("userTouser", fields: [managerId], references: [id], map: "User_managerId_fkey")
|
||||
subordinates User[] @relation("userTouser")
|
||||
permissions String? @db.LongText
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
followup.client.phone,
|
||||
followup.client.name,
|
||||
new Date(followup.date).toLocaleString()
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to send followup WhatsApp:', err.message);
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
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) { }
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createProductDto: CreateProductDto) {
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,34 +5,114 @@ import { UpdateProductDto } from './dto/update-product.dto';
|
|||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
Loading…
Reference in New Issue