diff --git a/packages/crm/src/hooks/account.hook.ts b/packages/crm/src/hooks/account.hook.ts index eb79fe6..3f559de 100644 --- a/packages/crm/src/hooks/account.hook.ts +++ b/packages/crm/src/hooks/account.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; @@ -14,7 +13,7 @@ import { db } from '../db'; */ const AccountHealthScoreTrigger: Hook = { name: 'AccountHealthScoreTrigger', - object: 'Account', + object: 'account', events: ['beforeInsert', 'beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -92,7 +91,7 @@ async function calculateHealthScore(account: Record, ctx: any): Pro */ const AccountHierarchyTrigger: Hook = { name: 'AccountHierarchyTrigger', - object: 'Account', + object: 'account', events: ['afterInsert', 'afterUpdate'], handler: async (ctx: HookContext) => { try { @@ -172,7 +171,7 @@ async function cascadeOwnershipChange(accountId: string, newOwnerId: string, ctx */ const AccountStatusAutomationTrigger: Hook = { name: 'AccountStatusAutomationTrigger', - object: 'Account', + object: 'account', events: ['beforeUpdate'], handler: async (ctx: HookContext) => { try { diff --git a/packages/crm/src/hooks/activity.hook.ts b/packages/crm/src/hooks/activity.hook.ts index a386ba2..fa9e204 100644 --- a/packages/crm/src/hooks/activity.hook.ts +++ b/packages/crm/src/hooks/activity.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; @@ -9,7 +8,7 @@ import { db } from '../db'; * Automatically completes past-due activities * This would typically run as a daily batch job */ -export async function autoCompletePastDueActivities(db: any): Promise { +export async function autoCompletePastDueActivities(ql: any): Promise { console.log('🔄 Running auto-complete for past-due activities...'); // In real implementation: @@ -44,7 +43,7 @@ export async function autoCompletePastDueActivities(db: any): Promise { */ const ActivityRelatedObjectUpdatesTrigger: Hook = { name: 'ActivityRelatedObjectUpdatesTrigger', - object: 'Activity', + object: 'activity', events: ['afterInsert', 'afterUpdate'], handler: async (ctx: HookContext) => { try { @@ -127,7 +126,7 @@ async function updateWhatObjectLastActivityDate(whatId: string, activityDate: st */ const ActivityCompletionTrigger: Hook = { name: 'ActivityCompletionTrigger', - object: 'Activity', + object: 'activity', events: ['beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -241,7 +240,7 @@ async function createNextRecurrence(activity: Record, ctx: any): Pr * * Daily job to find and notify about overdue activities */ -export async function sendOverdueNotifications(db: any): Promise { +export async function sendOverdueNotifications(ql: any): Promise { console.log('🔄 Finding overdue activities...'); // In real implementation: @@ -286,7 +285,7 @@ export async function sendOverdueNotifications(db: any): Promise { */ const ActivityTypeValidationTrigger: Hook = { name: 'ActivityTypeValidationTrigger', - object: 'Activity', + object: 'activity', events: ['beforeInsert', 'beforeUpdate'], handler: async (ctx: HookContext) => { try { diff --git a/packages/crm/src/hooks/contact.hook.ts b/packages/crm/src/hooks/contact.hook.ts index 8ece6c3..dbee556 100644 --- a/packages/crm/src/hooks/contact.hook.ts +++ b/packages/crm/src/hooks/contact.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; @@ -9,7 +8,7 @@ import { db } from '../db'; * Updates LastContactDate when activities are created/updated * This hook is actually called from Activity hooks */ -export async function updateContactLastContactDate(contactId: string, activityDate: string, db: any): Promise { +export async function updateContactLastContactDate(contactId: string, activityDate: string, ql: any): Promise { console.log(`🔄 Updating last contact date for contact: ${contactId}`); // Get current contact to check if update is needed @@ -34,7 +33,7 @@ export async function updateContactLastContactDate(contactId: string, activityDa */ const ContactDecisionChainTrigger: Hook = { name: 'ContactDecisionChainTrigger', - object: 'Contact', + object: 'contact', events: ['beforeInsert', 'beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -85,7 +84,7 @@ const ContactDecisionChainTrigger: Hook = { */ const ContactDecisionMakerValidationTrigger: Hook = { name: 'ContactDecisionMakerValidationTrigger', - object: 'Contact', + object: 'contact', events: ['afterInsert', 'afterUpdate'], handler: async (ctx: HookContext) => { try { @@ -126,7 +125,7 @@ const ContactDecisionMakerValidationTrigger: Hook = { */ const ContactDuplicateDetectionTrigger: Hook = { name: 'ContactDuplicateDetectionTrigger', - object: 'Contact', + object: 'contact', events: ['beforeInsert', 'beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -174,7 +173,7 @@ const ContactDuplicateDetectionTrigger: Hook = { */ const ContactRelationshipStrengthTrigger: Hook = { name: 'ContactRelationshipStrengthTrigger', - object: 'Contact', + object: 'contact', events: ['beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -236,7 +235,7 @@ function getDaysSince(dateString: string): number { * Update relationship strength based on activity analysis * This is called periodically or after significant activity changes */ -export async function analyzeAndUpdateRelationshipStrength(contactId: string, db: any): Promise { +export async function analyzeAndUpdateRelationshipStrength(contactId: string, ql: any): Promise { console.log(`🔄 Analyzing relationship strength for contact: ${contactId}`); // In real implementation: diff --git a/packages/crm/src/hooks/lead.hook.ts b/packages/crm/src/hooks/lead.hook.ts index 78ca08d..637fc5e 100644 --- a/packages/crm/src/hooks/lead.hook.ts +++ b/packages/crm/src/hooks/lead.hook.ts @@ -182,7 +182,7 @@ async function calculateLeadScore(lead: Lead, ctx: HookContext): Promise */ async function runAssignmentRules(lead: Lead, ctx: HookContext): Promise { try { - const rules: any[] = await (ctx.ql as any).find('assignment_rule', { + const rules: any[] = await ctx.ql.find('assignment_rule', { filters: [ ['object_name', '=', 'lead'], ['active', '=', true] @@ -318,14 +318,14 @@ const LeadStatusChangeTrigger: Hook = { // Log activity try { await (ctx.ql as any).doc.create('activity', { - Subject: `线索已转化: ${lead.FirstName} ${lead.LastName}`, + Subject: `Lead Converted: ${lead.FirstName} ${lead.LastName}`, Type: 'Conversion', Status: 'Completed', Priority: 'high', WhoId: lead.Id, OwnerId: ctx.session?.userId, ActivityDate: new Date().toISOString().split('T')[0], - Description: `线索 "${lead.FirstName} ${lead.LastName}" 来自 "${lead.Company}" 已成功转化` + Description: `Lead "${lead.FirstName} ${lead.LastName}" from "${lead.Company}" successfully converted` }); } catch (error) { console.error('❌ Failed to log conversion activity:', error); @@ -342,15 +342,15 @@ async function handleLeadConversion(ctx: HookContext): Promise { // Log activity for conversion try { - await (ctx.ql as any).doc.create('activity', { - Subject: `线索转换: ${lead.FirstName} ${lead.LastName}`, + await ctx.ql.doc.create('activity', { + Subject: `Lead Conversion: ${lead.FirstName} ${lead.LastName}`, Type: 'Conversion', Status: 'Completed', Priority: 'high', WhoId: lead.Id, OwnerId: ctx.session?.userId, ActivityDate: new Date().toISOString().split('T')[0], - Description: `线索 "${lead.FirstName} ${lead.LastName}" 已转换为客户` + Description: `Lead "${lead.FirstName} ${lead.LastName}" converted to customer` }); } catch (error) { console.error('❌ Failed to log conversion activity:', error); @@ -371,15 +371,15 @@ async function handleLeadUnqualification(ctx: HookContext): Promise { // Log activity try { - await (ctx.ql as any).doc.create('activity', { - Subject: `线索不合格: ${lead.FirstName} ${lead.LastName}`, + await ctx.ql.doc.create('activity', { + Subject: `Lead Unqualified: ${lead.FirstName} ${lead.LastName}`, Type: 'Disqualification', Status: 'Completed', Priority: 'Normal', WhoId: lead.Id, OwnerId: ctx.session?.userId, ActivityDate: new Date().toISOString().split('T')[0], - Description: `线索 "${lead.FirstName} ${lead.LastName}" 已标记为不合格` + Description: `Lead "${lead.FirstName} ${lead.LastName}" marked as unqualified` }); } catch (error) { console.error('❌ Failed to log unqualification activity:', error); @@ -394,15 +394,15 @@ async function logStatusChange(ctx: HookContext): Promise { const lead = ctx.input; const oldStatus = ctx.previous?.Status || 'Unknown'; - await (ctx.ql as any).doc.create('activity', { - Subject: `线索状态变更: ${oldStatus} → ${ctx.input.Status}`, + await ctx.ql.doc.create('activity', { + Subject: `Lead Status Change: ${oldStatus} → ${ctx.input.Status}`, Type: 'Status Change', Status: 'Completed', Priority: 'Normal', WhoId: lead.Id, OwnerId: ctx.session?.userId, ActivityDate: new Date().toISOString().split('T')[0], - Description: `线索状态从 "${oldStatus}" 变更为 "${ctx.input.Status}"` + Description: `Lead status changed from "${oldStatus}" to "${ctx.input.Status}"` }); } catch (error) { console.error('❌ Failed to log status change activity:', error); diff --git a/packages/crm/src/hooks/opportunity.hook.ts b/packages/crm/src/hooks/opportunity.hook.ts index 4fa268a..5728e69 100644 --- a/packages/crm/src/hooks/opportunity.hook.ts +++ b/packages/crm/src/hooks/opportunity.hook.ts @@ -1,11 +1,10 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; const OpportunityValidation: Hook = { name: 'OpportunityValidation', - object: 'Opportunity', + object: 'opportunity', events: ['beforeUpdate', 'beforeInsert'], handler: async (ctx: HookContext) => { const opp = ctx.input.doc as Record; @@ -39,7 +38,7 @@ const OpportunityValidation: Hook = { */ const OpportunityStageChange: Hook = { name: 'OpportunityStageChange', - object: 'Opportunity', + object: 'opportunity', events: ['afterUpdate'], handler: async (ctx: HookContext) => { try { @@ -97,7 +96,7 @@ async function handleClosedWon(ctx: any): Promise { // 1. Create Contract let contractId; try { - const contract = await ctx.db.doc.create('Contract', { + const contract = await ctx.ql.doc.create('contract', { AccountId: opportunity.AccountId, OpportunityId: opportunity.Id, Status: 'Draft', @@ -119,7 +118,7 @@ async function handleClosedWon(ctx: any): Promise { // 2. Update Account Status try { - await ctx.db.doc.update('Account', opportunity.AccountId, { + await ctx.ql.doc.update('account', opportunity.AccountId, { CustomerStatus: 'Active Customer' }); console.log('✅ Account status updated to Active Customer'); @@ -130,8 +129,8 @@ async function handleClosedWon(ctx: any): Promise { // 3. Log activity try { - await ctx.db.doc.create('Activity', { - Subject: `商机成交: ${opportunity.Name}`, + await ctx.ql.doc.create('activity', { + Subject: `Deal Won: ${opportunity.Name}`, Type: 'Milestone', Status: 'Completed', Priority: 'high', @@ -139,7 +138,7 @@ async function handleClosedWon(ctx: any): Promise { WhatId: opportunity.Id, OwnerId: ctx.user.id, ActivityDate: new Date().toISOString().split('T')[0], - Description: `商机 "${opportunity.Name}" 已成功成交,金额: ${opportunity.Amount?.toLocaleString() || 0}` + Description: `Opportunity "${opportunity.Name}" was successfully closed, amount: ${opportunity.Amount?.toLocaleString() || 0}` }); console.log('✅ Activity logged for Closed Won'); } catch (error) { @@ -171,8 +170,8 @@ async function handleClosedLost(ctx: any): Promise { // Log activity for lost opportunity try { - await ctx.db.doc.create('Activity', { - Subject: `商机丢失: ${opportunity.Name}`, + await ctx.ql.doc.create('activity', { + Subject: `Deal Lost: ${opportunity.Name}`, Type: 'Milestone', Status: 'Completed', Priority: 'Normal', @@ -180,7 +179,7 @@ async function handleClosedLost(ctx: any): Promise { WhatId: opportunity.Id, OwnerId: ctx.user.id, ActivityDate: new Date().toISOString().split('T')[0], - Description: `商机 "${opportunity.Name}" 已丢失,金额: ${opportunity.Amount?.toLocaleString() || 0}。原因待分析。` + Description: `Opportunity "${opportunity.Name}" was lost, amount: ${opportunity.Amount?.toLocaleString() || 0}. Reason pending analysis.` }); console.log('✅ Activity logged for Closed Lost'); } catch (error) { @@ -197,8 +196,8 @@ async function logStageChange(ctx: any): Promise { try { const opportunity = ctx.result; const oldStage = ctx.previous?.Stage || 'Unknown'; - await ctx.db.doc.create('Activity', { - Subject: `商机阶段变更: ${oldStage} → ${ctx.result.Stage}`, + await ctx.ql.doc.create('activity', { + Subject: `Opportunity Stage Change: ${oldStage} → ${ctx.result.Stage}`, Type: 'Stage Change', Status: 'Completed', Priority: 'Normal', @@ -206,7 +205,7 @@ async function logStageChange(ctx: any): Promise { WhatId: opportunity.Id, OwnerId: ctx.user.id, ActivityDate: new Date().toISOString().split('T')[0], - Description: `商机阶段从 "${oldStage}" 变更为 "${ctx.result.Stage}"` + Description: `Opportunity stage changed from "${oldStage}" to "${ctx.result.Stage}"` }); } catch (error) { console.error('❌ Failed to log stage change activity:', error); @@ -259,7 +258,7 @@ async function countRelatedQuotes(ctx: any, opportunityId: string): Promise ({ - db: { - find: vi.fn(), - insert: vi.fn(), - update: vi.fn() - } -})); - -import { db } from '../../../src/db'; import { ContractRenewalCheck, ContractExpirationAlert } from '../../../src/hooks/contract_renewal.hook'; // Helper to build a date N days from now @@ -17,8 +8,18 @@ function daysFromNow(days: number): string { } describe('ContractRenewalCheck', () => { + let mockQl: any; + beforeEach(() => { vi.resetAllMocks(); + mockQl = { + find: vi.fn(), + doc: { + create: vi.fn(), + update: vi.fn(), + get: vi.fn() + } + }; }); it('should create renewal opportunity when contract is activated and expires within 90 days', async () => { @@ -40,32 +41,33 @@ describe('ContractRenewalCheck', () => { }; const ctx = { result: newDoc, - previous: oldDoc + previous: oldDoc, + ql: mockQl }; // No existing renewal opportunity - (db.find as Mock).mockResolvedValueOnce([]); + mockQl.find.mockResolvedValueOnce([]); // Insert opportunity returns new record - (db.insert as Mock).mockResolvedValueOnce({ + mockQl.doc.create.mockResolvedValueOnce({ _id: 'opp_1', name: `Renewal: ${newDoc.contract_number}` }); // Insert task - (db.insert as Mock).mockResolvedValueOnce({ _id: 'task_1' }); + mockQl.doc.create.mockResolvedValueOnce({ _id: 'task_1' }); await ContractRenewalCheck.handler(ctx as any); - expect(db.find).toHaveBeenCalledWith('opportunity', { + expect(mockQl.find).toHaveBeenCalledWith('opportunity', { filters: [ ['source_contract', '=', 'contract_1'], ['type', '=', 'Renewal'] ] }); - expect(db.insert).toHaveBeenCalledTimes(2); + expect(mockQl.doc.create).toHaveBeenCalledTimes(2); // Verify opportunity creation - const oppCall = (db.insert as Mock).mock.calls[0]; + const oppCall = mockQl.doc.create.mock.calls[0]; expect(oppCall[0]).toBe('opportunity'); expect(oppCall[1]).toMatchObject({ name: 'Renewal: CNT-001', @@ -77,7 +79,7 @@ describe('ContractRenewalCheck', () => { }); // Verify task creation - const taskCall = (db.insert as Mock).mock.calls[1]; + const taskCall = mockQl.doc.create.mock.calls[1]; expect(taskCall[0]).toBe('task'); expect(taskCall[1]).toMatchObject({ related_to: 'acc_1', @@ -102,13 +104,14 @@ describe('ContractRenewalCheck', () => { contract_number: 'CNT-002', account: 'acc_2', contract_value: 30000 - } + }, + ql: mockQl }; await ContractRenewalCheck.handler(ctx as any); - expect(db.find).not.toHaveBeenCalled(); - expect(db.insert).not.toHaveBeenCalled(); + expect(mockQl.find).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); }); it('should not trigger when status does not change to Activated', async () => { @@ -128,13 +131,14 @@ describe('ContractRenewalCheck', () => { contract_number: 'CNT-003', account: 'acc_3', contract_value: 10000 - } + }, + ql: mockQl }; await ContractRenewalCheck.handler(ctx as any); - expect(db.find).not.toHaveBeenCalled(); - expect(db.insert).not.toHaveBeenCalled(); + expect(mockQl.find).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); }); it('should not create a duplicate opportunity if one already exists', async () => { @@ -154,15 +158,16 @@ describe('ContractRenewalCheck', () => { contract_number: 'CNT-004', account: 'acc_4', contract_value: 80000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([{ _id: 'existing_opp' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'existing_opp' }]); await ContractRenewalCheck.handler(ctx as any); - expect(db.find).toHaveBeenCalled(); - expect(db.insert).not.toHaveBeenCalled(); + expect(mockQl.find).toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); }); it('should handle errors gracefully', async () => { @@ -182,10 +187,11 @@ describe('ContractRenewalCheck', () => { contract_number: 'CNT-005', account: 'acc_5', contract_value: 20000 - } + }, + ql: mockQl }; - (db.find as Mock).mockRejectedValueOnce(new Error('DB connection failed')); + mockQl.find.mockRejectedValueOnce(new Error('DB connection failed')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -216,24 +222,35 @@ describe('ContractRenewalCheck', () => { contract_number: 'CNT-006', account: 'acc_6', contract_value: 60000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([]); - (db.insert as Mock) + mockQl.find.mockResolvedValueOnce([]); + mockQl.doc.create .mockResolvedValueOnce({ _id: 'opp_6', name: 'Renewal: CNT-006' }) .mockResolvedValueOnce({ _id: 'task_6' }); await ContractRenewalCheck.handler(ctx as any); - const taskCall = (db.insert as Mock).mock.calls[1]; + const taskCall = mockQl.doc.create.mock.calls[1]; expect(taskCall[1].priority).toBe('High'); }); }); describe('ContractExpirationAlert', () => { + let mockQl: any; + beforeEach(() => { vi.resetAllMocks(); + mockQl = { + find: vi.fn(), + doc: { + create: vi.fn(), + update: vi.fn(), + get: vi.fn() + } + }; }); it('should send alert when contract expires within 30 days', async () => { @@ -255,16 +272,17 @@ describe('ContractExpirationAlert', () => { account: 'acc_10', contract_value: 100000, renewal_reminder_sent: false - } + }, + ql: mockQl }; - (db.insert as Mock).mockResolvedValueOnce({ _id: 'activity_1' }); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.create.mockResolvedValueOnce({ _id: 'activity_1' }); + mockQl.doc.update.mockResolvedValueOnce({}); await ContractExpirationAlert.handler(ctx as any); // Should insert an alert activity - expect(db.insert).toHaveBeenCalledWith('activity', expect.objectContaining({ + expect(mockQl.doc.create).toHaveBeenCalledWith('activity', expect.objectContaining({ type: 'Alert', related_to: 'acc_10', contract: 'contract_10', @@ -273,7 +291,7 @@ describe('ContractExpirationAlert', () => { })); // Should flag the contract to prevent duplicate reminders - expect(db.update).toHaveBeenCalledWith('contract', 'contract_10', { + expect(mockQl.doc.update).toHaveBeenCalledWith('contract', 'contract_10', { renewal_reminder_sent: true }); }); @@ -297,13 +315,14 @@ describe('ContractExpirationAlert', () => { account: 'acc_11', contract_value: 50000, renewal_reminder_sent: false - } + }, + ql: mockQl }; await ContractExpirationAlert.handler(ctx as any); - expect(db.insert).not.toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should not alert when renewal_reminder_sent is already true', async () => { @@ -325,13 +344,14 @@ describe('ContractExpirationAlert', () => { account: 'acc_12', contract_value: 70000, renewal_reminder_sent: false - } + }, + ql: mockQl }; await ContractExpirationAlert.handler(ctx as any); - expect(db.insert).not.toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should not alert when status is not Activated', async () => { @@ -353,13 +373,14 @@ describe('ContractExpirationAlert', () => { account: 'acc_13', contract_value: 25000, renewal_reminder_sent: false - } + }, + ql: mockQl }; await ContractExpirationAlert.handler(ctx as any); - expect(db.insert).not.toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should handle errors gracefully', async () => { @@ -381,10 +402,11 @@ describe('ContractExpirationAlert', () => { account: 'acc_14', contract_value: 30000, renewal_reminder_sent: false - } + }, + ql: mockQl }; - (db.insert as Mock).mockRejectedValueOnce(new Error('Insert failed')); + mockQl.doc.create.mockRejectedValueOnce(new Error('Insert failed')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/packages/finance/src/hooks/contract.hook.ts b/packages/finance/src/hooks/contract.hook.ts index 6d339d0..43d385e 100644 --- a/packages/finance/src/hooks/contract.hook.ts +++ b/packages/finance/src/hooks/contract.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; const ContractBillingHook: Hook = { name: 'ContractBillingAutomation', @@ -29,10 +28,10 @@ const ContractBillingHook: Hook = { payment_terms: 'Net 30' }; - const invoice = await db.insert('invoice', invoiceData); + const invoice = await ctx.ql.doc.create('invoice', invoiceData); // Create a single line item for the full contract value (since we don't have contract lines yet) - await db.insert('invoice_line', { + await ctx.ql.doc.create('invoice_line', { invoice: invoice._id, description: `Contract ${newDoc.contract_number} Billing`, quantity: 1, diff --git a/packages/finance/src/hooks/contract_renewal.hook.ts b/packages/finance/src/hooks/contract_renewal.hook.ts index a4524bd..8f8c5b4 100644 --- a/packages/finance/src/hooks/contract_renewal.hook.ts +++ b/packages/finance/src/hooks/contract_renewal.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; const RENEWAL_WINDOW_DAYS = 90; const ALERT_WINDOW_DAYS = 30; @@ -28,7 +27,7 @@ const ContractRenewalCheck: Hook = { console.log(`🔄 Contract ${newDoc.contract_number} expires in ${daysUntilExpiry} days — creating renewal opportunity`); // Check if a renewal opportunity already exists for this contract - const existing = await db.find('opportunity', { + const existing = await ctx.ql.find('opportunity', { filters: [ ['source_contract', '=', newDoc._id], ['type', '=', 'Renewal'] @@ -44,7 +43,7 @@ const ContractRenewalCheck: Hook = { const closeDate = new Date(endDate); closeDate.setDate(closeDate.getDate() - 30); - const opportunity = await db.insert('opportunity', { + const opportunity = await ctx.ql.doc.create('opportunity', { name: `Renewal: ${newDoc.contract_number}`, account: newDoc.account, source_contract: newDoc._id, @@ -57,7 +56,7 @@ const ContractRenewalCheck: Hook = { console.log(`✅ Created renewal opportunity ${opportunity.name} for Contract ${newDoc.contract_number}`); // Create a task for the account owner to initiate renewal conversation - await db.insert('task', { + await ctx.ql.doc.create('task', { subject: `Initiate renewal for Contract ${newDoc.contract_number}`, description: `Contract ${newDoc.contract_number} expires on ${endDate.toISOString().split('T')[0]}. Please reach out to the customer to discuss renewal.`, related_to: newDoc.account, @@ -101,7 +100,7 @@ const ContractExpirationAlert: Hook = { console.log(`⚠️ Contract ${newDoc.contract_number} expires in ${daysUntilExpiry} days — sending expiration alert`); // Log urgent renewal activity - await db.insert('activity', { + await ctx.ql.doc.create('activity', { type: 'Alert', subject: `Urgent: Contract ${newDoc.contract_number} expiring in ${daysUntilExpiry} days`, description: `Contract ${newDoc.contract_number} with value ${newDoc.contract_value} is expiring on ${endDate.toISOString().split('T')[0]}. Immediate action required for renewal.`, @@ -112,7 +111,7 @@ const ContractExpirationAlert: Hook = { }); // Flag contract so we don't send duplicate reminders - await db.update('contract', newDoc._id, { + await ctx.ql.doc.update('contract', newDoc._id, { renewal_reminder_sent: true }); diff --git a/packages/hr/src/hooks/candidate.hook.ts b/packages/hr/src/hooks/candidate.hook.ts index 2661b62..152c697 100644 --- a/packages/hr/src/hooks/candidate.hook.ts +++ b/packages/hr/src/hooks/candidate.hook.ts @@ -13,13 +13,13 @@ const CandidateScoringTrigger: Hook = { name: 'CandidateScoringTrigger', object: 'candidate', events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const candidate = (ctx.input.doc as any) || ctx.input; + const candidate = ctx.input.doc as Record; // Check for duplicate candidates (same email) - if (ctx.event === 'beforeInsert' || (ctx.previous && candidate.email !== (ctx.previous as any).email)) { - const duplicates = await (ctx.ql as any).find('candidate', { + if (ctx.event === 'beforeInsert' || (ctx.previous && candidate.email !== (ctx.previous as Record).email)) { + const duplicates = await ctx.ql.find('candidate', { filters: [ ['email', '=', candidate.email], ['id', '!=', candidate.id || ''] @@ -151,15 +151,15 @@ const CandidateStatusChangeTrigger: Hook = { name: 'CandidateStatusChangeTrigger', object: 'candidate', events: ['afterUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { // Check if status changed - if (!ctx.previous || (ctx.previous as any).status === (ctx.result as any).status) { + if (!ctx.previous || (ctx.previous as Record).status === (ctx.result as Record).status) { return; } - const candidate = ctx.result as any; - const oldStatus = (ctx.previous as any).status; + const candidate = ctx.result as Record; + const oldStatus = (ctx.previous as Record).status; const newStatus = candidate.status; console.log(`🔄 Candidate status changed from "${oldStatus}" to "${newStatus}"`); diff --git a/packages/hr/src/hooks/employee.hook.ts b/packages/hr/src/hooks/employee.hook.ts index d49287d..6335d83 100644 --- a/packages/hr/src/hooks/employee.hook.ts +++ b/packages/hr/src/hooks/employee.hook.ts @@ -13,9 +13,9 @@ const EmployeeOnboardingTrigger: Hook = { name: 'EmployeeOnboardingTrigger', object: 'employee', events: ['afterInsert'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const employee = ctx.result as any; + const employee = ctx.result as Record; console.log(`👋 Initiating onboarding for employee: ${employee.first_name} ${employee.last_name}`); @@ -92,7 +92,7 @@ async function createProbationGoals(employee: any, ctx: any): Promise { const probationEndDate = new Date(hireDate); probationEndDate.setDate(probationEndDate.getDate() + 90); // 90-day probation - await (ctx.ql as any).doc.create('goal', { + await ctx.ql.doc.create('goal', { employee_id: employee.id, goal_name: `Complete Onboarding - ${employee.full_name}`, description: 'Successfully complete all onboarding tasks and probation requirements', @@ -118,15 +118,15 @@ const EmployeeStatusChangeTrigger: Hook = { name: 'EmployeeStatusChangeTrigger', object: 'employee', events: ['afterUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { // Check if employment status changed - if (!ctx.previous || (ctx.previous as any).employment_status === (ctx.result as any).employment_status) { + if (!ctx.previous || (ctx.previous as Record).employment_status === (ctx.result as Record).employment_status) { return; } - const employee = ctx.result as any; - const oldStatus = (ctx.previous as any).employment_status; + const employee = ctx.result as Record; + const oldStatus = (ctx.previous as Record).employment_status; const newStatus = employee.employment_status; console.log(`🔄 Employee status changed from "${oldStatus}" to "${newStatus}"`); @@ -224,9 +224,9 @@ const EmployeeDataValidationTrigger: Hook = { name: 'EmployeeDataValidationTrigger', object: 'employee', events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const employee = (ctx.input.doc as any) || ctx.input; + const employee = ctx.input.doc as Record; // Validate hire date is not in future if (employee.hire_date) { diff --git a/packages/hr/src/hooks/offer.hook.ts b/packages/hr/src/hooks/offer.hook.ts index 3918d94..9b8c24a 100644 --- a/packages/hr/src/hooks/offer.hook.ts +++ b/packages/hr/src/hooks/offer.hook.ts @@ -13,9 +13,9 @@ const OfferCreationTrigger: Hook = { name: 'OfferCreationTrigger', object: 'offer', events: ['beforeInsert'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const offer = (ctx.input.doc as any) || ctx.input; + const offer = ctx.input.doc as Record; // Generate offer number if not provided if (!offer.offer_number) { @@ -52,7 +52,7 @@ async function generateOfferNumber(ctx: any): Promise { const month = String(new Date().getMonth() + 1).padStart(2, '0'); // Find latest offer number for this year/month - const latestOffers = await (ctx.ql as any).find('offer', { + const latestOffers = await ctx.ql.find('offer', { filters: [ ['offer_number', 'like', `OFF-${year}${month}%`] ], @@ -79,15 +79,15 @@ const OfferStatusChangeTrigger: Hook = { name: 'OfferStatusChangeTrigger', object: 'offer', events: ['afterUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { // Check if status changed - if (!ctx.previous || (ctx.previous as any).status === (ctx.result as any).status) { + if (!ctx.previous || (ctx.previous as Record).status === (ctx.result as Record).status) { return; } - const offer = ctx.result as any; - const oldStatus = (ctx.previous as any).status; + const offer = ctx.result as Record; + const oldStatus = (ctx.previous as Record).status; const newStatus = offer.status; console.log(`🔄 Offer ${offer.offer_number} status changed from "${oldStatus}" to "${newStatus}"`); @@ -128,18 +128,18 @@ async function handleOfferSent(offer: any, ctx: any): Promise { try { // Update candidate status - await (ctx.ql as any).doc.update('candidate', offer.candidate_id, { + await ctx.ql.doc.update('candidate', offer.candidate_id, { status: 'Offer Sent' }); // Update application status - await (ctx.ql as any).doc.update('application', offer.application_id, { + await ctx.ql.doc.update('application', offer.application_id, { status: 'Offer Sent' }); // Set sent date if not already set if (!offer.sent_date) { - await (ctx.ql as any).doc.update('offer', offer.id, { + await ctx.ql.doc.update('offer', offer.id, { sent_date: new Date().toISOString().split('T')[0] }); } @@ -161,18 +161,18 @@ async function handleOfferAccepted(offer: any, ctx: any): Promise { try { // Update candidate status - await (ctx.ql as any).doc.update('candidate', offer.candidate_id, { + await ctx.ql.doc.update('candidate', offer.candidate_id, { status: 'Hired' }); // Update application status - await (ctx.ql as any).doc.update('application', offer.application_id, { + await ctx.ql.doc.update('application', offer.application_id, { status: 'Hired' }); // Set acceptance date if not already set if (!offer.acceptance_date) { - await (ctx.ql as any).doc.update('offer', offer.id, { + await ctx.ql.doc.update('offer', offer.id, { acceptance_date: new Date().toISOString().split('T')[0] }); } @@ -195,7 +195,7 @@ async function handleOfferAccepted(offer: any, ctx: any): Promise { async function createEmployeeFromOffer(offer: any, ctx: any): Promise { try { // Fetch candidate details - const candidate = await (ctx.ql as any).doc.get('candidate', offer.candidate_id, { + const candidate = await ctx.ql.doc.get('candidate', offer.candidate_id, { fields: ['first_name', 'last_name', 'email', 'mobile_phone', 'date_of_birth', 'gender'] }); @@ -207,7 +207,7 @@ async function createEmployeeFromOffer(offer: any, ctx: any): Promise { const employeeNumber = await generateEmployeeNumber(ctx); // Create employee record - const employee = await (ctx.ql as any).doc.create('employee', { + const employee = await ctx.ql.doc.create('employee', { employee_number: employeeNumber, first_name: candidate.first_name, last_name: candidate.last_name, @@ -226,7 +226,7 @@ async function createEmployeeFromOffer(offer: any, ctx: any): Promise { }); // Link employee to offer - await (ctx.ql as any).doc.update('offer', offer.id, { + await ctx.ql.doc.update('offer', offer.id, { employee_id: employee.id }); @@ -245,7 +245,7 @@ async function generateEmployeeNumber(ctx: any): Promise { const year = new Date().getFullYear(); // Find latest employee number for this year - const latestEmployees = await (ctx.ql as any).find('employee', { + const latestEmployees = await ctx.ql.find('employee', { filters: [ ['employee_number', 'like', `EMP${year}%`] ], @@ -271,18 +271,18 @@ async function handleOfferRejected(offer: any, ctx: any): Promise { try { // Update candidate status - await (ctx.ql as any).doc.update('candidate', offer.candidate_id, { + await ctx.ql.doc.update('candidate', offer.candidate_id, { status: 'Offer Rejected' }); // Update application status - await (ctx.ql as any).doc.update('application', offer.application_id, { + await ctx.ql.doc.update('application', offer.application_id, { status: 'Rejected' }); // Set rejection date if not already set if (!offer.rejection_date) { - await (ctx.ql as any).doc.update('offer', offer.id, { + await ctx.ql.doc.update('offer', offer.id, { rejection_date: new Date().toISOString().split('T')[0] }); } @@ -321,7 +321,7 @@ async function handleOfferWithdrawn(offer: any, ctx: any): Promise { try { // Update candidate status - await (ctx.ql as any).doc.update('candidate', offer.candidate_id, { + await ctx.ql.doc.update('candidate', offer.candidate_id, { status: 'Offer Withdrawn' }); @@ -359,10 +359,10 @@ const OfferApprovalTrigger: Hook = { name: 'OfferApprovalTrigger', object: 'offer', events: ['beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const offer = (ctx.input.doc as any) || ctx.input; - const previousOffer = ctx.previous as any; + const offer = ctx.input.doc as Record; + const previousOffer = ctx.previous as Record | undefined; // Check if approval status changed if (!previousOffer || previousOffer.approval_status === offer.approval_status) { diff --git a/packages/hr/src/hooks/performance_review.hook.ts b/packages/hr/src/hooks/performance_review.hook.ts index d009a3d..57e040e 100644 --- a/packages/hr/src/hooks/performance_review.hook.ts +++ b/packages/hr/src/hooks/performance_review.hook.ts @@ -22,9 +22,9 @@ const PerformanceReviewRatingTrigger: Hook = { name: 'PerformanceReviewRatingTrigger', object: 'performance_review', events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const review = (ctx.input.doc as any) || ctx.input; + const review = ctx.input.doc as Record; // Calculate overall rating from component scores if (hasAllComponentScores(review)) { @@ -132,15 +132,15 @@ const PerformanceReviewWorkflowTrigger: Hook = { name: 'PerformanceReviewWorkflowTrigger', object: 'performance_review', events: ['afterUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { // Check if status changed - if (!ctx.previous || (ctx.previous as any).status === (ctx.result as any).status) { + if (!ctx.previous || (ctx.previous as Record).status === (ctx.result as Record).status) { return; } - const review = ctx.result as any; - const oldStatus = (ctx.previous as any).status; + const review = ctx.result as Record; + const oldStatus = (ctx.previous as Record).status; const newStatus = review.status; console.log(`🔄 Performance review ${review.review_name} status changed from "${oldStatus}" to "${newStatus}"`); @@ -195,7 +195,7 @@ async function handleReviewSubmitted(review: any, ctx: any): Promise { try { // Set submission date - await (ctx.ql as any).doc.update('performance_review', review.id, { + await ctx.ql.doc.update('performance_review', review.id, { submitted_date: new Date().toISOString().split('T')[0], submitted_by: ctx.session?.userId }); @@ -217,7 +217,7 @@ async function handleReviewApproved(review: any, ctx: any): Promise { try { // Set approval date - await (ctx.ql as any).doc.update('performance_review', review.id, { + await ctx.ql.doc.update('performance_review', review.id, { approved_date: new Date().toISOString().split('T')[0], approved_by: ctx.session?.userId }); @@ -281,7 +281,7 @@ async function createDevelopmentGoals(review: any, ctx: any): Promise { } // Create development goal - await (ctx.ql as any).doc.create('goal', { + await ctx.ql.doc.create('goal', { employee_id: review.employee_id, goal_name: `Development Plan - ${review.review_period} Review`, description: review.development_plan, @@ -308,7 +308,7 @@ async function handleReviewCompleted(review: any, ctx: any): Promise { try { // Set completion date - await (ctx.ql as any).doc.update('performance_review', review.id, { + await ctx.ql.doc.update('performance_review', review.id, { completion_date: new Date().toISOString().split('T')[0] }); @@ -329,7 +329,7 @@ async function handleReviewCompleted(review: any, ctx: any): Promise { */ async function updateEmployeeReviewStats(review: any, ctx: any): Promise { try { - await (ctx.ql as any).doc.update('employee', review.employee_id, { + await ctx.ql.doc.update('employee', review.employee_id, { last_review_date: review.end_date, last_review_rating: review.overall_rating, last_review_level: review.performance_level @@ -350,7 +350,7 @@ async function handleReviewRejected(review: any, ctx: any): Promise { try { // Send back to reviewer for revision - await (ctx.ql as any).doc.update('performance_review', review.id, { + await ctx.ql.doc.update('performance_review', review.id, { status: 'In Progress' }); @@ -392,7 +392,7 @@ export async function checkPerformanceReviewDueDates(ctx: any): Promise { reminderDate.setDate(reminderDate.getDate() + 7); // 7 days before due // Find reviews due soon - const upcomingReviews = await (ctx.ql as any).find('performance_review', { + const upcomingReviews = await ctx.ql.find('performance_review', { filters: [ ['status', 'in', ['Not Started', 'In Progress']], ['due_date', '<=', reminderDate.toISOString().split('T')[0]], diff --git a/packages/marketing/__tests__/unit/hooks/campaign.hook.test.ts b/packages/marketing/__tests__/unit/hooks/campaign.hook.test.ts index 85e4fc2..587f55a 100644 --- a/packages/marketing/__tests__/unit/hooks/campaign.hook.test.ts +++ b/packages/marketing/__tests__/unit/hooks/campaign.hook.test.ts @@ -1,14 +1,5 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; -vi.mock('../../../src/db', () => ({ - db: { - find: vi.fn(), - insert: vi.fn(), - update: vi.fn() - } -})); - -import { db } from '../../../src/db'; import { CampaignROICalculationTrigger, CampaignBudgetTrackingTrigger, diff --git a/packages/marketing/__tests__/unit/hooks/roi.hook.test.ts b/packages/marketing/__tests__/unit/hooks/roi.hook.test.ts index e213249..4017500 100644 --- a/packages/marketing/__tests__/unit/hooks/roi.hook.test.ts +++ b/packages/marketing/__tests__/unit/hooks/roi.hook.test.ts @@ -1,19 +1,20 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; -vi.mock('../../../src/db', () => ({ - db: { - find: vi.fn(), - insert: vi.fn(), - update: vi.fn() - } -})); - -import { db } from '../../../src/db'; import { CampaignROIHook } from '../../../src/hooks/roi.hook'; describe('CampaignROIHook', () => { + let mockQl: any; + beforeEach(() => { vi.resetAllMocks(); + mockQl = { + find: vi.fn(), + doc: { + create: vi.fn(), + update: vi.fn(), + get: vi.fn() + } + }; }); it('should increase campaign revenue when opportunity is won', async () => { @@ -29,20 +30,21 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_1', stage_name: 'negotiation', amount: 50000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { _id: 'campaign_1', actual_revenue: 10000 } ]); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.update.mockResolvedValueOnce({}); await CampaignROIHook.handler(ctx as any); - expect(db.find).toHaveBeenCalledWith('campaign', { + expect(mockQl.find).toHaveBeenCalledWith('campaign', { filters: [['_id', '=', 'campaign_1']] }); - expect(db.update).toHaveBeenCalledWith('campaign', 'campaign_1', { + expect(mockQl.doc.update).toHaveBeenCalledWith('campaign', 'campaign_1', { actual_revenue: 60000 }); }); @@ -60,17 +62,18 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_2', stage_name: 'closed_won', amount: 30000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { _id: 'campaign_2', actual_revenue: 80000 } ]); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.update.mockResolvedValueOnce({}); await CampaignROIHook.handler(ctx as any); - expect(db.update).toHaveBeenCalledWith('campaign', 'campaign_2', { + expect(mockQl.doc.update).toHaveBeenCalledWith('campaign', 'campaign_2', { actual_revenue: 50000 }); }); @@ -88,18 +91,19 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_3', stage_name: 'closed_won', amount: 50000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { _id: 'campaign_3', actual_revenue: 100000 } ]); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.update.mockResolvedValueOnce({}); await CampaignROIHook.handler(ctx as any); // Delta = 70000 - 50000 = 20000; 100000 + 20000 = 120000 - expect(db.update).toHaveBeenCalledWith('campaign', 'campaign_3', { + expect(mockQl.doc.update).toHaveBeenCalledWith('campaign', 'campaign_3', { actual_revenue: 120000 }); }); @@ -115,13 +119,14 @@ describe('CampaignROIHook', () => { _id: 'opp_4', stage_name: 'negotiation', amount: 50000 - } + }, + ql: mockQl }; await CampaignROIHook.handler(ctx as any); - expect(db.find).not.toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.find).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should not process when stage did not change and amount is same', async () => { @@ -137,13 +142,14 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_5', stage_name: 'negotiation', amount: 50000 - } + }, + ql: mockQl }; await CampaignROIHook.handler(ctx as any); - expect(db.find).not.toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.find).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should handle campaign with no existing revenue (zero)', async () => { @@ -159,17 +165,18 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_6', stage_name: 'prospecting', amount: 25000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { _id: 'campaign_6' } ]); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.update.mockResolvedValueOnce({}); await CampaignROIHook.handler(ctx as any); - expect(db.update).toHaveBeenCalledWith('campaign', 'campaign_6', { + expect(mockQl.doc.update).toHaveBeenCalledWith('campaign', 'campaign_6', { actual_revenue: 25000 }); }); @@ -187,15 +194,16 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_7', stage_name: 'negotiation', amount: 10000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([]); + mockQl.find.mockResolvedValueOnce([]); await CampaignROIHook.handler(ctx as any); - expect(db.find).toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.find).toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should handle zero amount on won opportunity', async () => { @@ -211,14 +219,15 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_8', stage_name: 'negotiation', amount: 0 - } + }, + ql: mockQl }; // delta = 0, so no update should happen await CampaignROIHook.handler(ctx as any); - expect(db.find).not.toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.find).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should handle missing amount as zero when won', async () => { @@ -232,14 +241,15 @@ describe('CampaignROIHook', () => { _id: 'opp_9', campaign_id: 'campaign_9', stage_name: 'negotiation' - } + }, + ql: mockQl }; // amount is undefined → defaults to 0, delta = 0 await CampaignROIHook.handler(ctx as any); - expect(db.find).not.toHaveBeenCalled(); - expect(db.update).not.toHaveBeenCalled(); + expect(mockQl.find).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); }); it('should handle no previous state (new record)', async () => { @@ -249,19 +259,20 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_10', stage_name: 'closed_won', amount: 30000 - } + }, + ql: mockQl }; // wasWon = undefined?.stage_name === 'closed_won' → false // isWon = true, !wasWon = true → delta = 30000 - (db.find as Mock).mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { _id: 'campaign_10', actual_revenue: 5000 } ]); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.update.mockResolvedValueOnce({}); await CampaignROIHook.handler(ctx as any); - expect(db.update).toHaveBeenCalledWith('campaign', 'campaign_10', { + expect(mockQl.doc.update).toHaveBeenCalledWith('campaign', 'campaign_10', { actual_revenue: 35000 }); }); @@ -279,18 +290,19 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_11', stage_name: 'closed_won', amount: 40000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { _id: 'campaign_11', actual_revenue: 100000 } ]); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.update.mockResolvedValueOnce({}); await CampaignROIHook.handler(ctx as any); // delta = -(oldOpp.amount) = -40000 - expect(db.update).toHaveBeenCalledWith('campaign', 'campaign_11', { + expect(mockQl.doc.update).toHaveBeenCalledWith('campaign', 'campaign_11', { actual_revenue: 60000 }); }); @@ -308,18 +320,19 @@ describe('CampaignROIHook', () => { campaign_id: 'campaign_12', stage_name: 'closed_won', amount: 50000 - } + }, + ql: mockQl }; - (db.find as Mock).mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { _id: 'campaign_12', actual_revenue: 100000 } ]); - (db.update as Mock).mockResolvedValueOnce({}); + mockQl.doc.update.mockResolvedValueOnce({}); await CampaignROIHook.handler(ctx as any); // delta = 30000 - 50000 = -20000; 100000 + (-20000) = 80000 - expect(db.update).toHaveBeenCalledWith('campaign', 'campaign_12', { + expect(mockQl.doc.update).toHaveBeenCalledWith('campaign', 'campaign_12', { actual_revenue: 80000 }); }); diff --git a/packages/marketing/src/hooks/campaign.hook.ts b/packages/marketing/src/hooks/campaign.hook.ts index 367abf8..69f8d46 100644 --- a/packages/marketing/src/hooks/campaign.hook.ts +++ b/packages/marketing/src/hooks/campaign.hook.ts @@ -13,9 +13,9 @@ const CampaignROICalculationTrigger: Hook = { name: 'CampaignROICalculationTrigger', object: 'campaign', events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const campaign = ctx.input?.doc || ctx.input; + const campaign = ctx.input.doc as Record; // Calculate ROI: (ActualRevenue - ActualCost) / ActualCost * 100 const actualRevenue = campaign.actual_revenue || 0; @@ -57,10 +57,10 @@ const CampaignBudgetTrackingTrigger: Hook = { name: 'CampaignBudgetTrackingTrigger', object: 'campaign', events: ['beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const campaign = ctx.input?.doc || ctx.input; - const oldCampaign = ctx.previous; + const campaign = ctx.input.doc as Record; + const oldCampaign = ctx.previous as Record | undefined; // Only process if actual cost changed if (!oldCampaign || oldCampaign.actual_cost === campaign.actual_cost) { @@ -106,10 +106,10 @@ const CampaignStatusChangeTrigger: Hook = { name: 'CampaignStatusChangeTrigger', object: 'campaign', events: ['afterUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const campaign = ctx.result || ctx.input?.doc || ctx.input; - const oldCampaign = ctx.previous; + const campaign = ctx.result as Record; + const oldCampaign = ctx.previous as Record | undefined; // Check if status changed if (!oldCampaign || oldCampaign.status === campaign.status) { @@ -172,9 +172,9 @@ const CampaignDateValidationTrigger: Hook = { name: 'CampaignDateValidationTrigger', object: 'campaign', events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { - const campaign = ctx.input?.doc || ctx.input; + const campaign = ctx.input.doc as Record; if (campaign.start_date && campaign.end_date) { const startDate = new Date(campaign.start_date); @@ -208,7 +208,7 @@ export async function updateCampaignMetrics(campaignId: string, ctx: any): Promi console.log(`🔄 Updating campaign metrics for: ${campaignId}`); // Aggregate campaign member statistics - const members = await (ctx.ql as any).find('campaign_member', { + const members = await ctx.ql.find('campaign_member', { filters: [['campaign', '=', campaignId]] }); @@ -234,7 +234,7 @@ export async function updateCampaignMetrics(campaignId: string, ctx: any): Promi const unsubscribeRate = totalMembers > 0 ? (unsubscribed / totalMembers) * 100 : 0; // Update campaign with aggregated metrics - await (ctx.ql as any).update('campaign', campaignId, { + await ctx.ql.update('campaign', campaignId, { total_members: totalMembers, total_opened: opened, total_clicked: clicked, @@ -258,7 +258,7 @@ export async function updateCampaignMetrics(campaignId: string, ctx: any): Promi */ export async function calculateCostMetrics(campaignId: string, ctx: any): Promise { try { - const campaign = await (ctx.ql as any).findOne('campaign', { + const campaign = await ctx.ql.findOne('campaign', { filters: [['id', '=', campaignId]] }); diff --git a/packages/marketing/src/hooks/campaign_member.hook.ts b/packages/marketing/src/hooks/campaign_member.hook.ts index a0dc4f2..1bd7c31 100644 --- a/packages/marketing/src/hooks/campaign_member.hook.ts +++ b/packages/marketing/src/hooks/campaign_member.hook.ts @@ -14,7 +14,7 @@ const CampaignMemberEngagementTrigger: Hook = { name: 'CampaignMemberEngagementTrigger', object: 'campaign_member', events: ['beforeInsert', 'beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { const member = ctx.input.doc as Record; const oldMember = ctx.previous as Record | undefined; @@ -84,7 +84,7 @@ const CampaignMemberLeadScoringTrigger: Hook = { name: 'CampaignMemberLeadScoringTrigger', object: 'campaign_member', events: ['afterUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { const member = ctx.result as Record; const oldMember = ctx.previous as Record | undefined; @@ -152,7 +152,7 @@ const CampaignMemberStatsTrigger: Hook = { name: 'CampaignMemberStatsTrigger', object: 'campaign_member', events: ['afterInsert', 'afterUpdate', 'afterDelete'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { const member = ctx.result as Record; const campaignId = member?.campaign || ctx.previous?.campaign; @@ -182,7 +182,7 @@ const CampaignMemberBounceHandlerTrigger: Hook = { name: 'CampaignMemberBounceHandlerTrigger', object: 'campaign_member', events: ['beforeUpdate'], - handler: async (ctx: any) => { + handler: async (ctx: HookContext) => { try { const member = ctx.input.doc as Record; const oldMember = ctx.previous as Record | undefined; diff --git a/packages/marketing/src/hooks/roi.hook.ts b/packages/marketing/src/hooks/roi.hook.ts index 64ad1f6..0f8a5ee 100644 --- a/packages/marketing/src/hooks/roi.hook.ts +++ b/packages/marketing/src/hooks/roi.hook.ts @@ -1,5 +1,4 @@ -import type { Hook } from '@objectstack/spec/data'; -import { db } from '../db'; +import type { Hook, HookContext } from '@objectstack/spec/data'; /** * Campaign ROI Calculator @@ -42,12 +41,12 @@ const CampaignROIHook: Hook = { // Note: In a real concurrent system, use $inc operator. // objectstack/runtime usually supports partial updates. // If we can't use $inc, we risk race conditions, but for this prototype: - const campaigns = await db.find('campaign', { filters: [['_id', '=', newOpp.campaign_id]] }); + const campaigns = await ctx.ql.find('campaign', { filters: [['_id', '=', newOpp.campaign_id]] }); if (campaigns && campaigns.length > 0) { const campaign: any = campaigns[0]; const currentRevenue = campaign.actual_revenue || 0; - await db.update('campaign', newOpp.campaign_id, { + await ctx.ql.doc.update('campaign', newOpp.campaign_id, { actual_revenue: currentRevenue + delta }); } diff --git a/packages/products/__tests__/unit/hooks/pricebook.hook.test.ts b/packages/products/__tests__/unit/hooks/pricebook.hook.test.ts index 584f448..39d686f 100644 --- a/packages/products/__tests__/unit/hooks/pricebook.hook.test.ts +++ b/packages/products/__tests__/unit/hooks/pricebook.hook.test.ts @@ -2,16 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import PricebookHook from '../../../src/hooks/pricebook.hook'; describe('PricebookHook', () => { - let mockDb: any; + let mockQl: any; let mockUser: any; beforeEach(() => { vi.resetAllMocks(); - mockDb = { + mockQl = { find: vi.fn().mockResolvedValue([]), doc: { update: vi.fn().mockResolvedValue({}), - create: vi.fn().mockResolvedValue({ _id: 'activity_1' }) + create: vi.fn().mockResolvedValue({ _id: 'activity_1' }), + get: vi.fn() } }; mockUser = { id: 'user_1' }; @@ -29,7 +30,7 @@ describe('PricebookHook', () => { ExpirationDate: '2025-05-01' } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -48,7 +49,7 @@ describe('PricebookHook', () => { ExpirationDate: '2025-06-01' } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -67,7 +68,7 @@ describe('PricebookHook', () => { ExpirationDate: '2025-12-31' } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -76,7 +77,7 @@ describe('PricebookHook', () => { it('should warn when multiple active standard pricebooks overlap', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - mockDb.find.mockResolvedValueOnce([{ Id: 'pb_other', IsStandard: true }]); + mockQl.find.mockResolvedValueOnce([{ Id: 'pb_other', IsStandard: true }]); const ctx = { input: { @@ -88,13 +89,13 @@ describe('PricebookHook', () => { ExpirationDate: '2025-12-31' } }, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.find).toHaveBeenCalledWith('Pricebook', { + expect(mockQl.find).toHaveBeenCalledWith('pricebook', { filters: [ ['IsStandard', '=', true], ['Status', '=', 'Active'], @@ -119,7 +120,7 @@ describe('PricebookHook', () => { CurrencyCode: 'EUR' } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -140,7 +141,7 @@ describe('PricebookHook', () => { ExchangeRate: 1.5 } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -164,7 +165,7 @@ describe('PricebookHook', () => { ExchangeRate: 5.0 } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -201,13 +202,13 @@ describe('PricebookHook', () => { EffectiveDate: '2030-01-01', ExpirationDate: futureDate }, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Pricebook', 'pb_8', { + expect(mockQl.doc.update).toHaveBeenCalledWith('pricebook', 'pb_8', { Status: 'Active' }); }); @@ -234,13 +235,13 @@ describe('PricebookHook', () => { EffectiveDate: '2019-01-01', ExpirationDate: '2030-12-31' }, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Pricebook', 'pb_9', { + expect(mockQl.doc.update).toHaveBeenCalledWith('pricebook', 'pb_9', { Status: 'Expired' }); }); @@ -262,19 +263,19 @@ describe('PricebookHook', () => { IsStandard: false }, old: {}, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Pricebook', 'pb_10', { + expect(mockQl.doc.update).toHaveBeenCalledWith('pricebook', 'pb_10', { EffectiveDate: expect.any(String) }); }); it('should deactivate other standard pricebooks when a standard pricebook is activated', async () => { - mockDb.find.mockResolvedValueOnce([ + mockQl.find.mockResolvedValueOnce([ { Id: 'pb_old_std1' }, { Id: 'pb_old_std2' } ]); @@ -294,23 +295,23 @@ describe('PricebookHook', () => { IsStandard: true }, old: {}, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.find).toHaveBeenCalledWith('Pricebook', { + expect(mockQl.find).toHaveBeenCalledWith('pricebook', { filters: [ ['IsStandard', '=', true], ['Status', '=', 'Active'], ['Id', '!=', 'pb_11'] ] }); - expect(mockDb.doc.update).toHaveBeenCalledWith('Pricebook', 'pb_old_std1', expect.objectContaining({ + expect(mockQl.doc.update).toHaveBeenCalledWith('pricebook', 'pb_old_std1', expect.objectContaining({ Status: 'Inactive' })); - expect(mockDb.doc.update).toHaveBeenCalledWith('Pricebook', 'pb_old_std2', expect.objectContaining({ + expect(mockQl.doc.update).toHaveBeenCalledWith('pricebook', 'pb_old_std2', expect.objectContaining({ Status: 'Inactive' })); }); @@ -330,13 +331,13 @@ describe('PricebookHook', () => { IsStandard: false }, old: {}, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Pricebook', 'pb_12', { + expect(mockQl.doc.update).toHaveBeenCalledWith('pricebook', 'pb_12', { ExpirationDate: expect.any(String) }); }); @@ -357,13 +358,13 @@ describe('PricebookHook', () => { IsStandard: false }, old: {}, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ + expect(mockQl.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ Subject: 'Pricebook Status Changed: Draft → Active', Type: 'Status Change', WhatId: 'pb_13' @@ -389,13 +390,13 @@ describe('PricebookHook', () => { Status: 'Active' }, old: {}, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ + expect(mockQl.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ Subject: 'Pricebook Currency Updated: Currency PB', Type: 'Currency Update', Priority: 'high' @@ -423,14 +424,14 @@ describe('PricebookHook', () => { ExpirationDate: '2025-12-31' }, old: {}, - db: mockDb, + ql: mockQl, user: mockUser }; await PricebookHook.handler(ctx as any); - expect(mockDb.doc.update).not.toHaveBeenCalled(); - expect(mockDb.doc.create).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); }); it('should handle errors gracefully and rethrow', async () => { @@ -445,7 +446,7 @@ describe('PricebookHook', () => { ExpirationDate: '2025-01-01' } }, - db: mockDb, + ql: mockQl, user: mockUser }; diff --git a/packages/products/__tests__/unit/hooks/product.hook.test.ts b/packages/products/__tests__/unit/hooks/product.hook.test.ts index 47f38e1..f53ac64 100644 --- a/packages/products/__tests__/unit/hooks/product.hook.test.ts +++ b/packages/products/__tests__/unit/hooks/product.hook.test.ts @@ -2,16 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import ProductHook from '../../../src/hooks/product.hook'; describe('ProductHook', () => { - let mockDb: any; + let mockQl: any; let mockUser: any; beforeEach(() => { vi.resetAllMocks(); - mockDb = { + mockQl = { find: vi.fn().mockResolvedValue([]), doc: { update: vi.fn().mockResolvedValue({}), - create: vi.fn().mockResolvedValue({ _id: 'activity_1' }) + create: vi.fn().mockResolvedValue({ _id: 'activity_1' }), + get: vi.fn() } }; mockUser = { id: 'user_1' }; @@ -20,7 +21,7 @@ describe('ProductHook', () => { // ── Before Hook: Product Validation ── it('should validate product with unique product code (no duplicates)', async () => { - mockDb.find.mockResolvedValueOnce([]); + mockQl.find.mockResolvedValueOnce([]); const ctx = { input: { @@ -32,13 +33,13 @@ describe('ProductHook', () => { CostPrice: 50 } }, - db: mockDb, + ql: mockQl, user: mockUser }; await ProductHook.handler(ctx as any); - expect(mockDb.find).toHaveBeenCalledWith('Product', { + expect(mockQl.find).toHaveBeenCalledWith('product', { filters: [ ['ProductCode', '=', 'WGT-001'], ['Id', '!=', 'prod_1'] @@ -47,7 +48,7 @@ describe('ProductHook', () => { }); it('should throw error when product code already exists', async () => { - mockDb.find.mockResolvedValueOnce([{ Id: 'prod_existing' }]); + mockQl.find.mockResolvedValueOnce([{ Id: 'prod_existing' }]); const ctx = { input: { @@ -59,7 +60,7 @@ describe('ProductHook', () => { CostPrice: 50 } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -70,7 +71,7 @@ describe('ProductHook', () => { it('should warn when cost price exceeds list price', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - mockDb.find.mockResolvedValueOnce([]); + mockQl.find.mockResolvedValueOnce([]); const ctx = { input: { @@ -82,7 +83,7 @@ describe('ProductHook', () => { CostPrice: 100 } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -96,7 +97,7 @@ describe('ProductHook', () => { it('should validate bundle configuration for bundle products', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockDb.find.mockResolvedValueOnce([]); + mockQl.find.mockResolvedValueOnce([]); const ctx = { input: { @@ -108,7 +109,7 @@ describe('ProductHook', () => { ListPrice: 200 } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -122,7 +123,7 @@ describe('ProductHook', () => { it('should log dependency info for products with required products', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockDb.find.mockResolvedValueOnce([]); + mockQl.find.mockResolvedValueOnce([]); const ctx = { input: { @@ -134,7 +135,7 @@ describe('ProductHook', () => { ListPrice: 100 } }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -168,13 +169,13 @@ describe('ProductHook', () => { ListPrice: 100, CostPrice: 50 }, - db: mockDb, + ql: mockQl, user: mockUser }; await ProductHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Product', 'prod_6', { + expect(mockQl.doc.update).toHaveBeenCalledWith('product', 'prod_6', { Status: 'Out of Stock' }); }); @@ -201,7 +202,7 @@ describe('ProductHook', () => { ListPrice: 100, CostPrice: 50 }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -231,13 +232,13 @@ describe('ProductHook', () => { ListPrice: 100, CostPrice: 50 }, - db: mockDb, + ql: mockQl, user: mockUser }; await ProductHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Product', 'prod_8', { + expect(mockQl.doc.update).toHaveBeenCalledWith('product', 'prod_8', { Status: 'Active' }); }); @@ -262,13 +263,13 @@ describe('ProductHook', () => { StockLevel: 100, Status: 'Active' }, - db: mockDb, + ql: mockQl, user: mockUser }; await ProductHook.handler(ctx as any); - expect(mockDb.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ + expect(mockQl.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ Subject: 'Price Change: Price Change Widget', Type: 'Price Update', Status: 'Completed', @@ -297,13 +298,13 @@ describe('ProductHook', () => { CostPrice: 50, StockLevel: 100 }, - db: mockDb, + ql: mockQl, user: mockUser }; await ProductHook.handler(ctx as any); - expect(mockDb.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ + expect(mockQl.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ Subject: 'Product Status Changed: Active → Inactive', Type: 'Status Change', WhatId: 'prod_10' @@ -330,7 +331,7 @@ describe('ProductHook', () => { CostPrice: 50, StockLevel: 100 }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -360,18 +361,18 @@ describe('ProductHook', () => { CostPrice: 50, StockLevel: 100 }, - db: mockDb, + ql: mockQl, user: mockUser }; await ProductHook.handler(ctx as any); - expect(mockDb.doc.update).not.toHaveBeenCalled(); - expect(mockDb.doc.create).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); }); it('should handle errors gracefully and rethrow', async () => { - mockDb.find.mockRejectedValueOnce(new Error('DB failure')); + mockQl.find.mockRejectedValueOnce(new Error('DB failure')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const ctx = { @@ -383,7 +384,7 @@ describe('ProductHook', () => { ListPrice: 100 } }, - db: mockDb, + ql: mockQl, user: mockUser }; diff --git a/packages/products/__tests__/unit/hooks/quote.hook.test.ts b/packages/products/__tests__/unit/hooks/quote.hook.test.ts index 2c46993..3d67891 100644 --- a/packages/products/__tests__/unit/hooks/quote.hook.test.ts +++ b/packages/products/__tests__/unit/hooks/quote.hook.test.ts @@ -2,17 +2,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { QuotePricingHook } from '../../../src/hooks/quote.hook'; describe('QuotePricingHook', () => { - let mockDb: any; + let mockQl: any; let mockUser: any; beforeEach(() => { vi.resetAllMocks(); - mockDb = { + mockQl = { find: vi.fn().mockResolvedValue([]), insert: vi.fn().mockResolvedValue({ _id: 'contract_1', contract_number: 'CT-Q001' }), doc: { update: vi.fn().mockResolvedValue({}), - create: vi.fn().mockResolvedValue({ _id: 'activity_1' }) + create: vi.fn().mockResolvedValue({ _id: 'activity_1' }), + get: vi.fn() } }; mockUser = { id: 'user_1' }; @@ -32,7 +33,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -55,7 +56,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -79,7 +80,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -101,7 +102,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -123,7 +124,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -142,7 +143,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -162,7 +163,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -186,7 +187,7 @@ describe('QuotePricingHook', () => { const ctx = { input: { doc }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -211,22 +212,22 @@ describe('QuotePricingHook', () => { TotalPrice: 5000, ValidityPeriodDays: 45 }, - db: mockDb, + ql: mockQl, user: mockUser }; await QuotePricingHook.handler(ctx as any); // Should create activity - expect(mockDb.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ + expect(mockQl.doc.create).toHaveBeenCalledWith('activity', expect.objectContaining({ Subject: 'New Quote Created: New Quote', - Type: 'Quote', + Type: 'quote', AccountId: 'acc_1', WhatId: 'q_9' })); // Should set expiration date (45 days from 2025-01-15) - expect(mockDb.doc.update).toHaveBeenCalledWith('Quote', 'q_9', { + expect(mockQl.doc.update).toHaveBeenCalledWith('quote', 'q_9', { ExpirationDate: '2025-03-01' }); }); @@ -241,14 +242,14 @@ describe('QuotePricingHook', () => { AccountId: 'acc_2', TotalPrice: 3000 }, - db: mockDb, + ql: mockQl, user: mockUser }; await QuotePricingHook.handler(ctx as any); // 30 days from 2025-01-01 = 2025-01-31 - expect(mockDb.doc.update).toHaveBeenCalledWith('Quote', 'q_10', { + expect(mockQl.doc.update).toHaveBeenCalledWith('quote', 'q_10', { ExpirationDate: '2025-01-31' }); }); @@ -276,25 +277,25 @@ describe('QuotePricingHook', () => { OpportunityId: 'opp_1', TotalPrice: 25000 }, - db: mockDb, + ql: mockQl, user: mockUser }; await QuotePricingHook.handler(ctx as any); // Should update accepted date - expect(mockDb.doc.update).toHaveBeenCalledWith('Quote', 'q_11', { + expect(mockQl.doc.update).toHaveBeenCalledWith('quote', 'q_11', { AcceptedDate: expect.any(String) }); // Should update opportunity stage to closed_won - expect(mockDb.doc.update).toHaveBeenCalledWith('Opportunity', 'opp_1', { + expect(mockQl.doc.update).toHaveBeenCalledWith('opportunity', 'opp_1', { Stage: 'closed_won', CloseDate: expect.any(String) }); // Should create contract - expect(mockDb.insert).toHaveBeenCalledWith('contract', expect.objectContaining({ + expect(mockQl.insert).toHaveBeenCalledWith('contract', expect.objectContaining({ contract_number: 'CT-Q-003', account: 'acc_3', opportunity: 'opp_1', @@ -303,7 +304,7 @@ describe('QuotePricingHook', () => { })); // Should log status change activity - expect(mockDb.doc.create).toHaveBeenCalledWith('Activity', expect.objectContaining({ + expect(mockQl.doc.create).toHaveBeenCalledWith('activity', expect.objectContaining({ Subject: 'Quote Status Changed: Draft → Accepted', Type: 'Quote Status Change', AccountId: 'acc_3' @@ -324,7 +325,7 @@ describe('QuotePricingHook', () => { QuoteNumber: 'Q-004', Status: 'Draft' }, - db: mockDb, + ql: mockQl, user: mockUser }; @@ -332,7 +333,7 @@ describe('QuotePricingHook', () => { // handleQuoteSent runs but only logs // No activity should be created since no AccountId - expect(mockDb.doc.create).not.toHaveBeenCalledWith('Activity', expect.objectContaining({ + expect(mockQl.doc.create).not.toHaveBeenCalledWith('activity', expect.objectContaining({ Type: 'Quote Status Change' })); }); @@ -357,13 +358,13 @@ describe('QuotePricingHook', () => { ApprovalStatus: 'Pending', AccountId: 'acc_4' }, - db: mockDb, + ql: mockQl, user: mockUser }; await QuotePricingHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Quote', 'q_13', { + expect(mockQl.doc.update).toHaveBeenCalledWith('quote', 'q_13', { ApprovedDate: expect.any(String), ApprovedById: 'user_1', Status: 'Approved' @@ -388,13 +389,13 @@ describe('QuotePricingHook', () => { ApprovalStatus: 'Pending', AccountId: 'acc_5' }, - db: mockDb, + ql: mockQl, user: mockUser }; await QuotePricingHook.handler(ctx as any); - expect(mockDb.doc.update).toHaveBeenCalledWith('Quote', 'q_14', { + expect(mockQl.doc.update).toHaveBeenCalledWith('quote', 'q_14', { RejectedDate: expect.any(String), RejectedById: 'user_1', Status: 'Rejected' @@ -417,15 +418,15 @@ describe('QuotePricingHook', () => { ApprovalStatus: 'None', AccountId: 'acc_6' }, - db: mockDb, + ql: mockQl, user: mockUser }; await QuotePricingHook.handler(ctx as any); - expect(mockDb.doc.update).not.toHaveBeenCalled(); - expect(mockDb.doc.create).not.toHaveBeenCalled(); - expect(mockDb.insert).not.toHaveBeenCalled(); + expect(mockQl.doc.update).not.toHaveBeenCalled(); + expect(mockQl.doc.create).not.toHaveBeenCalled(); + expect(mockQl.insert).not.toHaveBeenCalled(); }); it('should handle errors gracefully and rethrow', async () => { @@ -438,7 +439,7 @@ describe('QuotePricingHook', () => { throw new Error('Unexpected error'); } }, - db: mockDb, + ql: mockQl, user: mockUser }; diff --git a/packages/products/src/hooks/pricebook.hook.ts b/packages/products/src/hooks/pricebook.hook.ts index 804d292..c9bc215 100644 --- a/packages/products/src/hooks/pricebook.hook.ts +++ b/packages/products/src/hooks/pricebook.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; @@ -14,7 +13,7 @@ import { db } from '../db'; */ const PricebookHook: Hook = { name: 'PricebookHook', - object: 'Pricebook', + object: 'pricebook', events: ['beforeInsert', 'beforeUpdate', 'afterUpdate'], handler: async (ctx: HookContext) => { try { @@ -61,7 +60,7 @@ async function validatePricebookDates(ctx: any): Promise { // Check for overlapping pricebooks (if this is a standard pricebook) if (pricebook.IsStandard && pricebook.EffectiveDate) { - const overlapping = await ctx.db.find('Pricebook', { + const overlapping = await ctx.ql.find('pricebook', { filters: [ ['IsStandard', '=', true], ['Status', '=', 'Active'], @@ -138,7 +137,7 @@ async function handleEffectiveDateChange(ctx: any): Promise { if (effectiveDateOnly <= today && pricebook.Status === 'Draft') { console.log(`✅ Pricebook effective date reached, activating: ${pricebook.Name}`); - await ctx.db.doc.update('Pricebook', pricebook.Id, { + await ctx.ql.doc.update('pricebook', pricebook.Id, { Status: 'Active' }); } @@ -152,7 +151,7 @@ async function handleEffectiveDateChange(ctx: any): Promise { if (expirationDateOnly < today && pricebook.Status === 'Active') { console.log(`⏰ Pricebook expired, deactivating: ${pricebook.Name}`); - await ctx.db.doc.update('Pricebook', pricebook.Id, { + await ctx.ql.doc.update('pricebook', pricebook.Id, { Status: 'Expired' }); } @@ -168,7 +167,7 @@ async function handleEffectiveDateChange(ctx: any): Promise { changes.push(`Expiration Date: ${ctx.old.ExpirationDate} → ${pricebook.ExpirationDate}`); } - await ctx.db.doc.create('Activity', { + await ctx.ql.doc.create('activity', { Subject: `Pricebook Dates Updated: ${pricebook.Name}`, Type: 'Pricebook Update', Status: 'Completed', @@ -204,7 +203,7 @@ async function handleStatusChange(ctx: any): Promise { // Set effective date if not already set if (!pricebook.EffectiveDate) { - await ctx.db.doc.update('Pricebook', pricebook.Id, { + await ctx.ql.doc.update('pricebook', pricebook.Id, { EffectiveDate: new Date().toISOString().split('T')[0] }); console.log('📅 Effective date set to today'); @@ -213,7 +212,7 @@ async function handleStatusChange(ctx: any): Promise { // If this is a standard pricebook, deactivate other standard pricebooks if (pricebook.IsStandard) { try { - const otherStandard = await ctx.db.find('Pricebook', { + const otherStandard = await ctx.ql.find('pricebook', { filters: [ ['IsStandard', '=', true], ['Status', '=', 'Active'], @@ -225,7 +224,7 @@ async function handleStatusChange(ctx: any): Promise { if (otherPricebooks.length > 0) { console.log(`⚠️ Deactivating ${otherPricebooks.length} other standard pricebook(s)`); for (const pb of otherPricebooks) { - await ctx.db.doc.update('Pricebook', pb.Id, { + await ctx.ql.doc.update('pricebook', pb.Id, { Status: 'Inactive', ExpirationDate: new Date().toISOString().split('T')[0] }); @@ -245,7 +244,7 @@ async function handleStatusChange(ctx: any): Promise { // Set expiration date if not already set if (!pricebook.ExpirationDate) { - await ctx.db.doc.update('Pricebook', pricebook.Id, { + await ctx.ql.doc.update('pricebook', pricebook.Id, { ExpirationDate: new Date().toISOString().split('T')[0] }); } @@ -254,7 +253,7 @@ async function handleStatusChange(ctx: any): Promise { } // Log activity for status change - await ctx.db.doc.create('Activity', { + await ctx.ql.doc.create('activity', { Subject: `Pricebook Status Changed: ${ctx.previous.Status} → ${pricebook.Status}`, Type: 'Status Change', Status: 'Completed', @@ -307,7 +306,7 @@ async function handleCurrencyChange(ctx: any): Promise { changes.push(`Exchange Rate: ${ctx.previous.ExchangeRate} → ${pricebook.ExchangeRate}`); } - await ctx.db.doc.create('Activity', { + await ctx.ql.doc.create('activity', { Subject: `Pricebook Currency Updated: ${pricebook.Name}`, Type: 'Currency Update', Status: 'Completed', @@ -328,7 +327,7 @@ async function handleCurrencyChange(ctx: any): Promise { */ async function activatePricebookEntries( pricebookId: string, - db: any + ql: any ): Promise { try { console.log(`✅ Activating pricebook entries for pricebook: ${pricebookId}`); @@ -344,7 +343,7 @@ async function activatePricebookEntries( */ async function expirePricebookEntries( pricebookId: string, - db: any + ql: any ): Promise { try { console.log(`⏰ Expiring pricebook entries for pricebook: ${pricebookId}`); diff --git a/packages/products/src/hooks/product.hook.ts b/packages/products/src/hooks/product.hook.ts index 523a2e2..7c5ffc2 100644 --- a/packages/products/src/hooks/product.hook.ts +++ b/packages/products/src/hooks/product.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; @@ -14,7 +13,7 @@ import { db } from '../db'; */ const ProductHook: Hook = { name: 'ProductHook', - object: 'Product', + object: 'product', events: ['beforeInsert', 'beforeUpdate', 'afterUpdate'], handler: async (ctx: HookContext) => { try { @@ -51,7 +50,7 @@ async function validateProductConfiguration(ctx: any): Promise { try { // Ensure product code is unique if (product.ProductCode) { - const existing = await ctx.db.find('Product', { + const existing = await ctx.ql.find('product', { filters: [ ['ProductCode', '=', product.ProductCode], ['Id', '!=', product.Id || ''] @@ -134,7 +133,7 @@ async function handleStockLevelChange(ctx: any): Promise { // Update product status to Out of Stock if (product.Status === 'Active') { - await ctx.db.doc.update('Product', product.Id, { + await ctx.ql.doc.update('product', product.Id, { Status: 'Out of Stock' }); console.log('🚫 Product status updated to Out of Stock'); @@ -143,7 +142,7 @@ async function handleStockLevelChange(ctx: any): Promise { // If stock was replenished, reactivate product if (ctx.previous.StockLevel === 0 && product.StockLevel > 0 && product.Status === 'Out of Stock') { - await ctx.db.doc.update('Product', product.Id, { + await ctx.ql.doc.update('product', product.Id, { Status: 'Active' }); console.log('✅ Product reactivated after stock replenishment'); @@ -171,7 +170,7 @@ async function handlePriceChange(ctx: any): Promise { console.log(`💰 List price changed from ${ctx.previous.ListPrice} to ${product.ListPrice}`); // Log price change activity - await ctx.db.doc.create('Activity', { + await ctx.ql.doc.create('activity', { Subject: `Price Change: ${product.Name}`, Type: 'Price Update', Status: 'Completed', @@ -236,7 +235,7 @@ async function handleStatusChange(ctx: any): Promise { } // Log activity for status change - await ctx.db.doc.create('Activity', { + await ctx.ql.doc.create('activity', { Subject: `Product Status Changed: ${ctx.previous.Status} → ${product.Status}`, Type: 'Status Change', Status: 'Completed', @@ -257,7 +256,7 @@ async function handleStatusChange(ctx: any): Promise { */ async function validateBundleDependencies( bundleId: string, - db: any + ql: any ): Promise { try { console.debug(`[product.hook] Bundle dependency validation pending: query ProductBundleItem and ProductBundleDependency for bundle ${bundleId}`); @@ -274,7 +273,7 @@ async function validateBundleDependencies( */ async function checkBundleConstraints( bundleId: string, - db: any + ql: any ): Promise { try { console.debug(`[product.hook] Bundle constraint check pending: query ProductBundleConstraint for bundle ${bundleId}`); diff --git a/packages/products/src/hooks/quote.hook.ts b/packages/products/src/hooks/quote.hook.ts index b348a88..aecd354 100644 --- a/packages/products/src/hooks/quote.hook.ts +++ b/packages/products/src/hooks/quote.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; @@ -163,7 +162,7 @@ async function initializeQuote(ctx: any): Promise { try { // Log activity for new quote if (quote.AccountId) { - await ctx.db.doc.create('Activity', { + await ctx.ql.doc.create('activity', { Subject: `New Quote Created: ${quote.Name}`, Type: 'Quote', Status: 'Completed', @@ -183,7 +182,7 @@ async function initializeQuote(ctx: any): Promise { const quoteDate = new Date(quote.QuoteDate); const expirationDate = new Date(quoteDate.getTime() + validityDays * 24 * 60 * 60 * 1000); - await ctx.db.doc.update('Quote', quote.Id, { + await ctx.ql.doc.update('quote', quote.Id, { ExpirationDate: expirationDate.toISOString().split('T')[0] }); console.log(`📅 Expiration date set to: ${expirationDate.toISOString().split('T')[0]}`); @@ -224,7 +223,7 @@ async function handleQuoteStatusChange(ctx: any): Promise { // Log activity for status change if (quote.AccountId) { - await ctx.db.doc.create('Activity', { + await ctx.ql.doc.create('activity', { Subject: `Quote Status Changed: ${ctx.previous.Status} → ${quote.Status}`, Type: 'Quote Status Change', Status: 'Completed', @@ -251,14 +250,14 @@ async function handleQuoteAccepted(ctx: any): Promise { console.log('✅ Processing quote acceptance...'); // Update accepted date and timestamp - await ctx.db.doc.update('Quote', quote.Id, { + await ctx.ql.doc.update('quote', quote.Id, { AcceptedDate: new Date().toISOString() }); // Update opportunity stage if linked if (quote.OpportunityId) { try { - await ctx.db.doc.update('Opportunity', quote.OpportunityId, { + await ctx.ql.doc.update('opportunity', quote.OpportunityId, { Stage: 'closed_won', CloseDate: new Date().toISOString().split('T')[0] }); @@ -287,11 +286,11 @@ async function handleQuoteAccepted(ctx: any): Promise { }; try { - const contract = await ctx.db.insert('contract', contractData); + const contract = await ctx.ql.doc.create('contract', contractData); console.log(`📝 Contract created successfully: ${contract._id}`); // Link Contract back to Quote - await ctx.db.doc.update('Quote', quote.Id, { + await ctx.ql.doc.update('quote', quote.Id, { Description: `${quote.Description || ''}\n Contract Created: ${contract.contract_number}` }); } catch (err) { @@ -349,7 +348,7 @@ async function handleApprovalStatusChange(ctx: any): Promise { // Handle approved if (quote.ApprovalStatus === 'Approved') { - await ctx.db.doc.update('Quote', quote.Id, { + await ctx.ql.doc.update('quote', quote.Id, { ApprovedDate: new Date().toISOString(), ApprovedById: ctx.user.id, Status: quote.Status === 'In Review' ? 'Approved' : quote.Status @@ -359,7 +358,7 @@ async function handleApprovalStatusChange(ctx: any): Promise { // Handle rejected if (quote.ApprovalStatus === 'Rejected') { - await ctx.db.doc.update('Quote', quote.Id, { + await ctx.ql.doc.update('quote', quote.Id, { RejectedDate: new Date().toISOString(), RejectedById: ctx.user.id, Status: 'Rejected' diff --git a/packages/support/__tests__/unit/hooks/case.hook.test.ts b/packages/support/__tests__/unit/hooks/case.hook.test.ts index a7f9533..1474e67 100644 --- a/packages/support/__tests__/unit/hooks/case.hook.test.ts +++ b/packages/support/__tests__/unit/hooks/case.hook.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; -vi.mock('../../../src/db', () => ({ - db: { - find: vi.fn(), - insert: vi.fn(), - update: vi.fn() - } -})); - -import { db } from '../../../src/db'; import { CaseEntitlementCheck } from '../../../src/hooks/case.hook'; describe('CaseEntitlementCheck', () => { + let mockQl: any; + beforeEach(() => { - vi.resetAllMocks(); + mockQl = { + find: vi.fn(), + doc: { + create: vi.fn(), + update: vi.fn(), + get: vi.fn() + } + }; }); it('should assign Platinum SLA with 4-hour resolution and critical priority', async () => { @@ -22,13 +22,16 @@ describe('CaseEntitlementCheck', () => { subject: 'Urgent Issue', priority: 'high' }; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce([{ _id: 'acc_1', sla: 'Platinum' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'acc_1', sla: 'Platinum' }]); await CaseEntitlementCheck.handler(ctx as any); - expect(db.find).toHaveBeenCalledWith('account', { + expect(mockQl.find).toHaveBeenCalledWith('account', { filters: [['_id', '=', 'acc_1']] }); expect(caseDoc.entitlement_name).toBe('Platinum Support'); @@ -49,9 +52,12 @@ describe('CaseEntitlementCheck', () => { subject: 'Gold Issue', priority: 'medium' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce([{ _id: 'acc_2', sla: 'Gold' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'acc_2', sla: 'Gold' }]); await CaseEntitlementCheck.handler(ctx as any); @@ -70,9 +76,12 @@ describe('CaseEntitlementCheck', () => { subject: 'Critical Gold', priority: 'critical' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce([{ _id: 'acc_gold', sla: 'Gold' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'acc_gold', sla: 'Gold' }]); await CaseEntitlementCheck.handler(ctx as any); @@ -86,9 +95,12 @@ describe('CaseEntitlementCheck', () => { subject: 'Silver Issue', priority: 'medium' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce([{ _id: 'acc_3', sla: 'Silver' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'acc_3', sla: 'Silver' }]); await CaseEntitlementCheck.handler(ctx as any); @@ -107,10 +119,13 @@ describe('CaseEntitlementCheck', () => { subject: 'Standard Issue', priority: 'low' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; // Account without sla field - (db.find as Mock).mockResolvedValueOnce([{ _id: 'acc_4' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'acc_4' }]); await CaseEntitlementCheck.handler(ctx as any); @@ -125,11 +140,14 @@ describe('CaseEntitlementCheck', () => { it('should skip entitlement check when no account is linked', async () => { const caseDoc = { subject: 'No account case' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; await CaseEntitlementCheck.handler(ctx as any); - expect(db.find).not.toHaveBeenCalled(); + expect(mockQl.find).not.toHaveBeenCalled(); expect(caseDoc.entitlement_name).toBeUndefined(); expect(caseDoc.target_resolution_date).toBeUndefined(); }); @@ -139,13 +157,16 @@ describe('CaseEntitlementCheck', () => { account: 'acc_missing', subject: 'Orphan case' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce([]); + mockQl.find.mockResolvedValueOnce([]); await CaseEntitlementCheck.handler(ctx as any); - expect(db.find).toHaveBeenCalled(); + expect(mockQl.find).toHaveBeenCalled(); expect(caseDoc.entitlement_name).toBeUndefined(); expect(caseDoc.target_resolution_date).toBeUndefined(); }); @@ -155,9 +176,12 @@ describe('CaseEntitlementCheck', () => { account: 'acc_null', subject: 'Null result case' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce(null); + mockQl.find.mockResolvedValueOnce(null); await CaseEntitlementCheck.handler(ctx as any); @@ -169,9 +193,12 @@ describe('CaseEntitlementCheck', () => { account: 'acc_err', subject: 'Error case' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockRejectedValueOnce(new Error('DB connection failed')); + mockQl.find.mockRejectedValueOnce(new Error('DB connection failed')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -190,9 +217,12 @@ describe('CaseEntitlementCheck', () => { account: 'acc_iso', subject: 'ISO date test' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce([{ _id: 'acc_iso', sla: 'Silver' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'acc_iso', sla: 'Silver' }]); await CaseEntitlementCheck.handler(ctx as any); @@ -213,9 +243,12 @@ describe('CaseEntitlementCheck', () => { subject: 'Explicit Standard', priority: 'medium' } as Record; - const ctx = { input: { doc: caseDoc } }; + const ctx = { + input: { doc: caseDoc }, + ql: mockQl + }; - (db.find as Mock).mockResolvedValueOnce([{ _id: 'acc_std', sla: 'Standard' }]); + mockQl.find.mockResolvedValueOnce([{ _id: 'acc_std', sla: 'Standard' }]); await CaseEntitlementCheck.handler(ctx as any); diff --git a/packages/support/__tests__/unit/hooks/knowledge.hook.test.ts b/packages/support/__tests__/unit/hooks/knowledge.hook.test.ts index 6909aa8..f4cc50a 100644 --- a/packages/support/__tests__/unit/hooks/knowledge.hook.test.ts +++ b/packages/support/__tests__/unit/hooks/knowledge.hook.test.ts @@ -28,7 +28,7 @@ describe('KnowledgeArticleScoringTrigger', () => { it('should have correct hook metadata', () => { expect(KnowledgeArticleScoringTrigger.name).toBe('KnowledgeArticleScoringTrigger'); - expect(KnowledgeArticleScoringTrigger.object).toBe('KnowledgeArticle'); + expect(KnowledgeArticleScoringTrigger.object).toBe('knowledge_article'); expect(KnowledgeArticleScoringTrigger.events).toEqual(['beforeInsert', 'beforeUpdate']); }); @@ -318,7 +318,7 @@ describe('KnowledgeArticleAIEnhancementTrigger', () => { const ctx = { input: { doc: article }, previous: undefined, - db: { find: vi.fn().mockResolvedValue([relatedArticle]) } + ql: { find: vi.fn().mockResolvedValue([relatedArticle]) } }; await KnowledgeArticleAIEnhancementTrigger.handler(ctx as any); @@ -513,7 +513,7 @@ describe('KnowledgeArticleUsageTracker', () => { it('should have correct hook metadata', () => { expect(KnowledgeArticleUsageTracker.name).toBe('KnowledgeArticleUsageTracker'); - expect(KnowledgeArticleUsageTracker.object).toBe('Case'); + expect(KnowledgeArticleUsageTracker.object).toBe('case'); expect(KnowledgeArticleUsageTracker.events).toEqual(['afterUpdate']); }); @@ -531,7 +531,7 @@ describe('KnowledgeArticleUsageTracker', () => { const ctx = { result: caseRecord, previous: oldCase, - db: { + ql: { find: mockDbFind, doc: { update: mockDbUpdate } } @@ -541,10 +541,10 @@ describe('KnowledgeArticleUsageTracker', () => { expect(mockDbFind).toHaveBeenCalledTimes(2); expect(mockDbUpdate).toHaveBeenCalledTimes(2); - expect(mockDbUpdate).toHaveBeenCalledWith('KnowledgeArticle', 'art_1', expect.objectContaining({ + expect(mockDbUpdate).toHaveBeenCalledWith('knowledge_article', 'art_1', expect.objectContaining({ CaseResolutionCount: 6 })); - expect(mockDbUpdate).toHaveBeenCalledWith('KnowledgeArticle', 'art_2', expect.objectContaining({ + expect(mockDbUpdate).toHaveBeenCalledWith('knowledge_article', 'art_2', expect.objectContaining({ CaseResolutionCount: 11 })); }); @@ -558,12 +558,12 @@ describe('KnowledgeArticleUsageTracker', () => { const ctx = { result: caseRecord, previous: { Status: 'New' }, - db: { find: vi.fn(), doc: { update: vi.fn() } } + ql: { find: vi.fn(), doc: { update: vi.fn() } } }; await KnowledgeArticleUsageTracker.handler(ctx as any); - expect(ctx.db.find).not.toHaveBeenCalled(); + expect(ctx.ql.find).not.toHaveBeenCalled(); }); it('should not track when case was already resolved', async () => { @@ -575,12 +575,12 @@ describe('KnowledgeArticleUsageTracker', () => { const ctx = { result: caseRecord, previous: { Status: 'Resolved' }, - db: { find: vi.fn(), doc: { update: vi.fn() } } + ql: { find: vi.fn(), doc: { update: vi.fn() } } }; await KnowledgeArticleUsageTracker.handler(ctx as any); - expect(ctx.db.find).not.toHaveBeenCalled(); + expect(ctx.ql.find).not.toHaveBeenCalled(); }); it('should not track when no knowledge articles are linked', async () => { @@ -610,7 +610,7 @@ describe('KnowledgeArticleUsageTracker', () => { const ctx = { result: caseRecord, previous: { Status: 'Open' }, - db: { + ql: { find: mockDbFind, doc: { update: mockDbUpdate } } @@ -660,7 +660,7 @@ describe('KnowledgeArticleSearchAnalytics', () => { it('should have correct hook metadata', () => { expect(KnowledgeArticleSearchAnalytics.name).toBe('KnowledgeArticleSearchAnalytics'); - expect(KnowledgeArticleSearchAnalytics.object).toBe('KnowledgeArticle'); + expect(KnowledgeArticleSearchAnalytics.object).toBe('knowledge_article'); expect(KnowledgeArticleSearchAnalytics.events).toEqual(['afterFind']); }); diff --git a/packages/support/src/hooks/case.hook.ts b/packages/support/src/hooks/case.hook.ts index 707f114..a4d1ada 100644 --- a/packages/support/src/hooks/case.hook.ts +++ b/packages/support/src/hooks/case.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; // Ensure this points to the correct db instance /** * Entitlement Verification Hook @@ -23,7 +22,7 @@ const CaseEntitlementCheck: Hook = { try { // 1. Fetch Account to get SLA Level // Using direct ObjectQL find - const accounts = await db.find('account', { filters: [['_id', '=', caseRec.account]] }); + const accounts = await ctx.ql.find('account', { filters: [['_id', '=', caseRec.account]] }); if (!accounts || accounts.length === 0) { console.warn(`Warning: Linked Account ${caseRec.account} not found.`); diff --git a/packages/support/src/hooks/knowledge.hook.ts b/packages/support/src/hooks/knowledge.hook.ts index 091799a..8ec7661 100644 --- a/packages/support/src/hooks/knowledge.hook.ts +++ b/packages/support/src/hooks/knowledge.hook.ts @@ -1,5 +1,4 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import { db } from '../db'; @@ -13,7 +12,7 @@ import { db } from '../db'; */ const KnowledgeArticleScoringTrigger: Hook = { name: 'KnowledgeArticleScoringTrigger', - object: 'KnowledgeArticle', + object: 'knowledge_article', events: ['beforeInsert', 'beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -55,7 +54,7 @@ const KnowledgeArticleScoringTrigger: Hook = { */ const KnowledgeArticleAIEnhancementTrigger: Hook = { name: 'KnowledgeArticleAIEnhancementTrigger', - object: 'KnowledgeArticle', + object: 'knowledge_article', events: ['beforeInsert', 'beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -111,7 +110,7 @@ const KnowledgeArticleAIEnhancementTrigger: Hook = { */ const KnowledgeArticleWorkflowTrigger: Hook = { name: 'KnowledgeArticleWorkflowTrigger', - object: 'KnowledgeArticle', + object: 'knowledge_article', events: ['beforeUpdate'], handler: async (ctx: HookContext) => { try { @@ -152,7 +151,7 @@ const KnowledgeArticleWorkflowTrigger: Hook = { */ const KnowledgeArticleUsageTracker: Hook = { name: 'KnowledgeArticleUsageTracker', - object: 'Case', + object: 'case', events: ['afterUpdate'], handler: async (ctx: HookContext) => { try { @@ -185,7 +184,7 @@ const KnowledgeArticleUsageTracker: Hook = { */ const KnowledgeArticleSearchAnalytics: Hook = { name: 'KnowledgeArticleSearchAnalytics', - object: 'KnowledgeArticle', + object: 'knowledge_article', events: ['afterFind'], handler: async (ctx: HookContext) => { try { @@ -429,7 +428,7 @@ async function findRelatedArticles(article: any, ctx: any): Promise { if (keywords.length === 0) return []; // Find articles with similar keywords or category - const relatedArticles = await ctx.db.find('KnowledgeArticle', { + const relatedArticles = await ctx.ql.find('knowledge_article', { filters: [ ['Status', '=', 'Published'], ['id', '!=', article.id] @@ -504,14 +503,14 @@ function calculateNextReviewDate(article: any): Date { async function incrementArticleUsage(articleId: string, ctx: any): Promise { try { - const article = await ctx.db.find('KnowledgeArticle', { + const article = await ctx.ql.find('knowledge_article', { filters: [['id', '=', articleId]], limit: 1 }); if (article && article.length > 0) { const currentCount = article[0].CaseResolutionCount || 0; - await ctx.db.doc.update('KnowledgeArticle', articleId, { + await ctx.ql.doc.update('knowledge_article', articleId, { CaseResolutionCount: currentCount + 1, LastUsedInCaseDate: new Date() });