Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions e2e/opportunity-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { test, expect, type APIRequestContext } from '@playwright/test';

/**
* Opportunity lifecycle regression tests.
*
* These exercise the `opportunity_lifecycle` hook (src/objects/opportunity.hook.ts)
* end-to-end through the REST API, since L2 hooks are body-only sandboxed code
* and cannot be imported and unit-tested in isolation. The hook is the single
* source of truth for `probability` and `expected_revenue` (the duplicate
* field-update workflows were removed), so these assertions guard against drift.
*
* The CRM data API may be auth-gated depending on how the server is launched.
* When a write is rejected (401/403), the test self-skips rather than failing —
* mirroring the tolerance already used in e2e/smoke.spec.ts.
*/

const BASE = '/api/v1/data/crm_opportunity';

async function createOpportunity(
request: APIRequestContext,
body: Record<string, unknown>,
): Promise<{ skipped: boolean; status: number; data?: Record<string, unknown> }> {
const res = await request.post(BASE, { data: body });
if ([401, 403].includes(res.status())) return { skipped: true, status: res.status() };
expect(res.ok(), `create failed: ${res.status()} ${await res.text()}`).toBeTruthy();
const json = await res.json();
return { skipped: false, status: res.status(), data: json.data ?? json };
}

test('expected_revenue and probability are derived from stage on create', async ({ request }) => {
const created = await createOpportunity(request, {
name: 'E2E Pipeline Deal',
stage: 'proposal', // probability 60
amount: 10000,
close_date: '2099-12-31',
});
test.skip(created.skipped, `data API is auth-gated (status ${created.status})`);

const rec = created.data!;
// Hook syncs probability to the stage's canonical value …
expect(rec.probability).toBe(60);
// … and recomputes expected_revenue = amount * probability/100.
expect(rec.expected_revenue).toBe(6000);

// cleanup (best-effort)
if (rec.id) await request.delete(`${BASE}/${rec.id}`);
});

test('closed_won stamps close_date and recomputes to 100% probability', async ({ request }) => {
const created = await createOpportunity(request, {
name: 'E2E Won Deal',
stage: 'negotiation',
amount: 25000,
close_date: '2099-12-31',
});
test.skip(created.skipped, `data API is auth-gated (status ${created.status})`);

const id = created.data!.id as string;
const res = await request.patch(`${BASE}/${id}`, { data: { stage: 'closed_won' } });
expect(res.ok(), `update failed: ${res.status()} ${await res.text()}`).toBeTruthy();
const updated = (await res.json()).data ?? (await res.json());

expect(updated.stage).toBe('closed_won');
expect(updated.probability).toBe(100);
expect(updated.expected_revenue).toBe(25000);
// Hook stamps close_date to today when entering closed_won.
expect(typeof updated.close_date).toBe('string');

if (id) await request.delete(`${BASE}/${id}`);
});
24 changes: 12 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,23 @@
},
"packageManager": "pnpm@10.33.0",
"dependencies": {
"@objectstack/account": "^7.0.0",
"@objectstack/cli": "^7.0.0",
"@objectstack/driver-memory": "^7.0.0",
"@objectstack/driver-sql": "^7.0.0",
"@objectstack/driver-sqlite-wasm": "^7.0.0",
"@objectstack/metadata": "^7.0.0",
"@objectstack/objectql": "^7.0.0",
"@objectstack/runtime": "^7.0.0",
"@objectstack/service-analytics": "^7.0.0",
"@objectstack/service-automation": "^7.0.0",
"@objectstack/spec": "^7.0.0"
"@objectstack/account": "^7.1.0",
"@objectstack/cli": "^7.1.0",
"@objectstack/driver-memory": "^7.1.0",
"@objectstack/driver-sql": "^7.1.0",
"@objectstack/driver-sqlite-wasm": "^7.1.0",
"@objectstack/metadata": "^7.1.0",
"@objectstack/objectql": "^7.1.0",
"@objectstack/runtime": "^7.1.0",
"@objectstack/service-analytics": "^7.1.0",
"@objectstack/service-automation": "^7.1.0",
"@objectstack/spec": "^7.1.0"
},
"optionalDependencies": {
"better-sqlite3": "^12.10.0"
},
"devDependencies": {
"@objectstack/cli": "^7.0.0",
"@objectstack/cli": "^7.1.0",
"@playwright/test": "^1.60.0",
"typescript": "^6.0.3"
},
Expand Down
616 changes: 308 additions & 308 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/objects/_hook-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Shared structural type for the ObjectQL data API exposed on `ctx.api`
* inside L2 hook handlers.
*
* The SDK types `HookContext.api` as `unknown` (it is injected by the runtime
* and intentionally not coupled to the spec package). Each hook previously
* hand-rolled its own narrow shape, which drifted (`filter` vs `where`, missing
* methods, etc.). This module is the single source of truth for that shape so
* every `*.hook.ts` can `import type { HookApi }` and stay consistent.
*
* Methods are the superset actually used across the CRM hooks. Cast with
* `ctx.api as HookApi | undefined` and guard for `undefined` before use.
*/

type Doc = Record<string, unknown>;

/** Query options accepted by read operations. Drivers accept `filter` or `where`. */
export interface HookQuery {
filter?: Doc;
where?: Doc;
fields?: string[];
top?: number;
}

export interface HookObjectApi {
count: (q: HookQuery) => Promise<number>;
find: (q: HookQuery) => Promise<Array<Doc>>;
findOne: (q: HookQuery) => Promise<Doc | null>;
insert: (doc: Doc) => Promise<unknown>;
update: (id: string, doc: Doc) => Promise<unknown>;
updateMany: (q: { where: Doc; doc: Doc }) => Promise<unknown>;
delete: (id: string) => Promise<unknown>;
}

export interface HookApi {
object: (name: string) => HookObjectApi;
}
9 changes: 2 additions & 7 deletions src/objects/account.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api';

/**
* Account protection hook.
Expand Down Expand Up @@ -39,13 +40,7 @@ const accountHook: Hook = {
if (event === 'beforeDelete') {
const previous = ctx.previous;
if (!previous || previous.type !== 'customer') return;
const api = ctx.api as
| {
object: (n: string) => {
count: (q: { filter: Record<string, unknown> }) => Promise<number>;
};
}
| undefined;
const api = ctx.api as HookApi | undefined;
if (!api) return;
const openOpps = await api.object('crm_opportunity').count({
filter: {
Expand Down
11 changes: 2 additions & 9 deletions src/objects/campaign.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api';

/**
* Campaign lifecycle hook.
Expand All @@ -10,14 +11,6 @@ import type { Hook, HookContext } from '@objectstack/spec/data';
* into the campaign's metric fields.
*/

type ApiShape = {
object: (n: string) => {
count: (q: { filter: Record<string, unknown> }) => Promise<number>;
find: (q: { filter: Record<string, unknown>; fields?: string[]; top?: number }) => Promise<Array<Record<string, unknown>>>;
update: (id: string, doc: Record<string, unknown>) => Promise<unknown>;
};
};

const campaignValidation: Hook = {
name: 'campaign_validation',
object: 'crm_campaign',
Expand Down Expand Up @@ -60,7 +53,7 @@ const campaignCompleted: Hook = {
const { input } = ctx;
const previous = ctx.previous;
if (input.status !== 'completed' || previous?.status === 'completed') return;
const api = ctx.api as ApiShape | undefined;
const api = ctx.api as HookApi | undefined;
if (!api) return;
const id =
(typeof input.id === 'string' && input.id) ||
Expand Down
11 changes: 2 additions & 9 deletions src/objects/case.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api';

/**
* Case SLA & escalation hook.
Expand All @@ -11,14 +12,6 @@ import type { Hook, HookContext } from '@objectstack/spec/data';
* - Declarative `condition` flags SLA breach when due date is past and case not closed.
*/

type ApiShape = {
object: (n: string) => {
findOne: (q: { filter: Record<string, unknown> }) => Promise<Record<string, unknown> | null>;
update: (id: string, doc: Record<string, unknown>) => Promise<unknown>;
insert: (doc: Record<string, unknown>) => Promise<unknown>;
};
};

const caseValidation: Hook = {
name: 'case_sla_defaults',
object: 'crm_case',
Expand Down Expand Up @@ -64,7 +57,7 @@ const caseSideEffects: Hook = {
const { input } = ctx;
const previous = ctx.previous;
if (!previous) return;
const api = ctx.api as ApiShape | undefined;
const api = ctx.api as HookApi | undefined;
if (!api) return;

const caseId =
Expand Down
6 changes: 4 additions & 2 deletions src/objects/case.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,8 @@ export const Case = ObjectSchema.create({
name: 'email_support_manager',
type: 'email_alert',
template: 'critical_case_alert',
recipients: ['support_manager@example.com'],
// Route to the case owner's manager instead of a fixed mailbox.
recipients: ['{owner}', '{owner.manager}'],
}
],
},
Expand All @@ -322,7 +323,8 @@ export const Case = ObjectSchema.create({
name: 'email_escalation_team',
type: 'email_alert',
template: 'case_escalation_alert',
recipients: ['escalation_team@example.com'],
// Escalations go to the owner's manager and the account owner.
recipients: ['{owner.manager}', '{crm_account.owner}'],
}
],
},
Expand Down
11 changes: 2 additions & 9 deletions src/objects/contact.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api';

/**
* Contact integrity hook.
Expand All @@ -12,14 +13,6 @@ import type { Hook, HookContext } from '@objectstack/spec/data';
* open quote or active contract.
*/

type ApiShape = {
object: (n: string) => {
count: (q: { where: Record<string, unknown> }) => Promise<number>;
findOne: (q: { where: Record<string, unknown> }) => Promise<Record<string, unknown> | null>;
updateMany: (q: { where: Record<string, unknown>; doc: Record<string, unknown> }) => Promise<unknown>;
};
};

const contactHook: Hook = {
name: 'contact_integrity',
object: 'crm_contact',
Expand All @@ -29,7 +22,7 @@ const contactHook: Hook = {
'Dedupe contacts per account, propagate contact info to linked opportunities, and protect referenced contacts from deletion.',
handler: async (ctx: HookContext) => {
const { event, input } = ctx;
const api = ctx.api as ApiShape | undefined;
const api = ctx.api as HookApi | undefined;

if ((event === 'beforeInsert' || event === 'beforeUpdate') && api) {
const email = typeof input.email === 'string' ? input.email.toLowerCase() : '';
Expand Down
11 changes: 2 additions & 9 deletions src/objects/contract.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api';

/**
* Contract lifecycle hook.
Expand All @@ -11,14 +12,6 @@ import type { Hook, HookContext } from '@objectstack/spec/data';
* and schedules a renewal task 60 days before `end_date`.
*/

type ApiShape = {
object: (n: string) => {
findOne: (q: { filter: Record<string, unknown> }) => Promise<Record<string, unknown> | null>;
update: (id: string, doc: Record<string, unknown>) => Promise<unknown>;
insert: (doc: Record<string, unknown>) => Promise<unknown>;
};
};

function monthsBetween(startISO: string, endISO: string): number {
const s = new Date(startISO);
const e = new Date(endISO);
Expand Down Expand Up @@ -104,7 +97,7 @@ const contractActivation: Hook = {
const { input } = ctx;
const previous = ctx.previous;
if (input.status !== 'activated' || previous?.status === 'activated') return;
const api = ctx.api as ApiShape | undefined;
const api = ctx.api as HookApi | undefined;
if (!api) return;

function addDays(iso: string, days: number): string {
Expand Down
9 changes: 2 additions & 7 deletions src/objects/lead.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api';

/**
* Lead automation hook.
Expand All @@ -10,12 +11,6 @@ import type { Hook, HookContext } from '@objectstack/spec/data';
* - When status flips to `qualified`, schedules a follow-up `crm_task` for the current user.
*/

type ApiShape = {
object: (n: string) => {
insert: (doc: Record<string, unknown>) => Promise<unknown>;
};
};

const HIGH_VALUE_INDUSTRIES = new Set([
'technology',
'finance',
Expand Down Expand Up @@ -113,7 +108,7 @@ const leadHook: Hook = {
input.status === 'qualified' && previous?.status !== 'qualified';
if (!becameQualified) return;

const api = ctx.api as ApiShape | undefined;
const api = ctx.api as HookApi | undefined;
if (!api) return;

const leadId =
Expand Down
22 changes: 2 additions & 20 deletions src/objects/opportunity.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api';

/**
* Opportunity lifecycle hook.
Expand All @@ -11,14 +12,6 @@ import type { Hook, HookContext } from '@objectstack/spec/data';
* and asynchronously schedules an "Activate customer" task.
*/

type ApiShape = {
object: (n: string) => {
findOne: (q: { filter: Record<string, unknown> }) => Promise<Record<string, unknown> | null>;
update: (id: string, doc: Record<string, unknown>) => Promise<unknown>;
insert: (doc: Record<string, unknown>) => Promise<unknown>;
};
};

const STAGE_PROBABILITY: Record<string, number> = {
prospecting: 10,
qualification: 25,
Expand All @@ -42,17 +35,6 @@ const opportunityValidationHook: Hook = {
const { event, input } = ctx;
const previous = ctx.previous;

const STAGE_PROBABILITY: Record<string, number> = {
prospecting: 10,
qualification: 25,
needs_analysis: 40,
proposal: 60,
negotiation: 80,
closed_won: 100,
closed_lost: 0,
};
const NARRATIVE_FIELDS = new Set(['description', 'next_step', 'notes']);

// Recompute expected_revenue
const amount =
typeof input.amount === 'number'
Expand Down Expand Up @@ -110,7 +92,7 @@ const opportunityWonHook: Hook = {
const previous = ctx.previous;
const becameWon = input.stage === 'closed_won' && previous?.stage !== 'closed_won';
if (!becameWon) return;
const api = ctx.api as ApiShape | undefined;
const api = ctx.api as HookApi | undefined;
if (!api) return;

const accountId =
Expand Down
Loading
Loading