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
19 changes: 19 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "crm-app",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 4001,
"autoPort": true
},
{
"name": "docs",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@hotcrm/docs", "dev"],
"port": 3210,
"autoPort": true
}
]
}
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@
},
"packageManager": "pnpm@10.33.0",
"dependencies": {
"@objectstack/account": "^7.4.1",
"@objectstack/cli": "^7.4.1",
"@objectstack/driver-memory": "^7.4.1",
"@objectstack/driver-sql": "^7.4.1",
"@objectstack/driver-sqlite-wasm": "^7.4.1",
"@objectstack/metadata": "^7.4.1",
"@objectstack/objectql": "^7.4.1",
"@objectstack/runtime": "^7.4.1",
"@objectstack/service-analytics": "^7.4.1",
"@objectstack/service-automation": "^7.4.1",
"@objectstack/spec": "^7.4.1"
"@objectstack/account": "^7.7.0",
"@objectstack/cli": "^7.7.0",
"@objectstack/driver-memory": "^7.7.0",
"@objectstack/driver-sql": "^7.7.0",
"@objectstack/driver-sqlite-wasm": "^7.7.0",
"@objectstack/metadata": "^7.7.0",
"@objectstack/objectql": "^7.7.0",
"@objectstack/runtime": "^7.7.0",
"@objectstack/service-analytics": "^7.7.0",
"@objectstack/service-automation": "^7.7.0",
"@objectstack/spec": "^7.7.0"
},
"optionalDependencies": {
"better-sqlite3": "^12.10.0"
Expand Down
866 changes: 443 additions & 423 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions src/flows/campaign-completion.flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type * as Automation from '@objectstack/spec/automation';
type Flow = Automation.Flow;

/**
* Campaign auto-completion — scheduled daily sweep.
*
* Migrated from the removed `campaign_completion_check` object workflow (7.7
* dropped `workflows[]` on object schemas; scheduled automation is now a
* `type: 'schedule'` flow). Each night it flips `in_progress` campaigns whose
* `end_date` has passed to `completed` — which the existing
* `campaign_snapshot_metrics` afterUpdate hook then snapshots into metrics.
*/
export const CampaignCompletionFlow: Flow = {
name: 'campaign_completion',
label: 'Campaign Auto-Completion',
description: 'Daily: mark in_progress campaigns whose end_date has passed as completed.',
type: 'schedule',
status: 'active',
variables: [],
nodes: [
{ id: 'start', type: 'start', label: 'Start (daily 02:00)', config: { schedule: '0 2 * * *' } },
{
id: 'query_campaigns', type: 'get_record', label: 'Find Ended Campaigns',
config: {
objectName: 'crm_campaign',
filter: { status: 'in_progress', end_date: { $lt: '{TODAY()}' } },
limit: 500,
outputVariable: 'campaignList',
},
},
{ id: 'loop_campaigns', type: 'loop', label: 'For Each Campaign', config: { collection: '{campaignList}', iteratorVariable: 'currentCampaign' } },
{
id: 'mark_completed', type: 'update_record', label: 'Mark Completed',
config: { objectName: 'crm_campaign', filter: { id: '{currentCampaign.id}' }, fields: { status: 'completed' } },
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'query_campaigns', type: 'default' },
{ id: 'e2', source: 'query_campaigns', target: 'loop_campaigns', type: 'default' },
{ id: 'e3', source: 'loop_campaigns', target: 'mark_completed', type: 'default' },
{ id: 'e4', source: 'mark_completed', target: 'end', type: 'default' },
],
};
7 changes: 5 additions & 2 deletions src/flows/case-escalation.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export const CaseEscalationFlow: Flow = {
id: 'assign_senior_agent', type: 'update_record', label: 'Assign to Senior Agent',
config: {
objectName: 'crm_case', filter: { id: '{record.id}' },
fields: { owner: '{caseRecord.owner.manager}', is_escalated: true, escalated_date: '{NOW()}', status: 'escalated' },
// `escalation_reason` must be set whenever `is_escalated` flips true — the
// object's `escalation_reason_required` validation rejects the write
// otherwise (which silently aborted this flow until it was supplied).
fields: { owner: '{caseRecord.owner.manager}', is_escalated: true, escalation_reason: 'Auto-escalated: critical priority', escalated_date: '{NOW()}', status: 'escalated' },
},
},
{
Expand All @@ -59,7 +62,7 @@ export const CaseEscalationFlow: Flow = {
config: {
to: ['{caseRecord.owner}', '{caseRecord.owner.manager}'],
channels: ['inbox', 'email'],
severity: 'high',
severity: 'critical',
topic: 'case_escalated',
title: 'Case escalated: {caseRecord.case_number}',
body: 'Case {caseRecord.case_number} ({caseRecord.priority}) for {caseRecord.crm_account.name} has been escalated and reassigned.',
Expand Down
2 changes: 1 addition & 1 deletion src/flows/case-sla-monitor.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const CaseSlaMonitorFlow: Flow = {
config: {
to: ['{currentCase.owner}', '{currentCase.owner.manager}'],
channels: ['inbox', 'email'],
severity: 'high',
severity: 'critical',
topic: 'case_sla_breach',
title: 'SLA breached: case {currentCase.case_number}',
body: 'Case {currentCase.case_number} ({currentCase.priority}) passed its SLA due date and has been auto-escalated.',
Expand Down
39 changes: 39 additions & 0 deletions src/flows/contact-welcome.flow.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.

import type * as Automation from '@objectstack/spec/automation';
type Flow = Automation.Flow;

/**
* Contact welcome — record-change flow on contact insert.
*
* Migrated from the removed `welcome_email` object workflow (7.7 dropped
* `workflows[]`; on-create automation is now a `record_change` flow). Sends a
* welcome notification to the new contact.
*/
export const ContactWelcomeFlow: Flow = {
name: 'contact_welcome',
label: 'Contact Welcome',
description: 'On new contact: send a welcome notification.',
type: 'record_change',
status: 'active',
variables: [],
nodes: [
{ id: 'start', type: 'start', label: 'Start (contact created)', config: { objectName: 'crm_contact', triggerType: 'record-after-create' } },
{
id: 'send_welcome', type: 'notify', label: 'Send Welcome',
config: {
to: ['{record.email}'],
channels: ['email'],
topic: 'contact_welcome',
title: 'Welcome, {record.full_name}',
body: 'Thanks for connecting with us, {record.full_name}. Your account team will be in touch shortly.',
actionUrl: '/crm_contact/{record.id}',
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'send_welcome', type: 'default' },
{ id: 'e2', source: 'send_welcome', target: 'end', type: 'default' },
],
};
57 changes: 57 additions & 0 deletions src/flows/contract-expiration.flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type * as Automation from '@objectstack/spec/automation';
type Flow = Automation.Flow;

/**
* Contract auto-expiration — scheduled daily sweep.
*
* Migrated from the removed `contract_expiration_check` object workflow (7.7
* dropped `workflows[]`). Flips `activated` contracts past their `end_date` to
* `expired` and notifies the owner. (Renewal *reminders* are a separate concern
* already handled by the `contract_renewal` flow.)
*/
export const ContractExpirationFlow: Flow = {
name: 'contract_expiration',
label: 'Contract Auto-Expiration',
description: 'Daily: expire activated contracts past their end_date and notify the owner.',
type: 'schedule',
status: 'active',
variables: [],
nodes: [
{ id: 'start', type: 'start', label: 'Start (daily 00:00)', config: { schedule: '0 0 * * *' } },
{
id: 'query_contracts', type: 'get_record', label: 'Find Expired Contracts',
config: {
objectName: 'crm_contract',
filter: { status: 'activated', end_date: { $lt: '{TODAY()}' } },
limit: 500,
outputVariable: 'contractList',
},
},
{ id: 'loop_contracts', type: 'loop', label: 'For Each Contract', config: { collection: '{contractList}', iteratorVariable: 'currentContract' } },
{
id: 'mark_expired', type: 'update_record', label: 'Mark Expired',
config: { objectName: 'crm_contract', filter: { id: '{currentContract.id}' }, fields: { status: 'expired' } },
},
{
id: 'notify_owner', type: 'notify', label: 'Notify Owner',
config: {
to: ['{currentContract.owner}'],
channels: ['inbox', 'email'],
topic: 'contract_expired',
title: 'Contract expired: {currentContract.contract_number}',
body: 'Contract {currentContract.contract_number} reached its end date and has been marked expired.',
actionUrl: '/crm_contract/{currentContract.id}',
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'query_contracts', type: 'default' },
{ id: 'e2', source: 'query_contracts', target: 'loop_contracts', type: 'default' },
{ id: 'e3', source: 'loop_contracts', target: 'mark_expired', type: 'default' },
{ id: 'e4', source: 'mark_expired', target: 'notify_owner', type: 'default' },
{ id: 'e5', source: 'notify_owner', target: 'end', type: 'default' },
],
};
12 changes: 6 additions & 6 deletions src/flows/contract-renewal.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const ContractRenewalFlow: Flow = {
},
{
id: 'check_notice_window', type: 'decision', label: 'Within Notice Window?',
config: { condition: '{currentContract.end_date} <= {TODAY() + currentContract.renewal_notice_days}' },
config: { condition: 'timestamp(currentContract.end_date) <= daysFromNow(int(currentContract.renewal_notice_days))' },
},
{
id: 'create_renewal_task', type: 'create_record', label: 'Create Renewal Task',
Expand Down Expand Up @@ -77,7 +77,7 @@ export const ContractRenewalFlow: Flow = {
},
{
id: 'check_auto_renewal', type: 'decision', label: 'Auto-Renewal On?',
config: { condition: '{currentContract.auto_renewal} == true' },
config: { condition: 'currentContract.auto_renewal == true' },
},
{
id: 'create_renewal_opp', type: 'create_record', label: 'Open Renewal Opportunity',
Expand All @@ -103,12 +103,12 @@ export const ContractRenewalFlow: Flow = {
{ id: 'e2', source: 'query_contracts', target: 'loop_contracts', type: 'default' },
{ id: 'e3', source: 'loop_contracts', target: 'check_notice_window', type: 'default' },
// Only act when the contract is inside its own notice window
{ id: 'e4', source: 'check_notice_window', target: 'create_renewal_task', type: 'conditional', condition: '{currentContract.end_date} <= {TODAY() + currentContract.renewal_notice_days}', label: 'In window' },
{ id: 'e5', source: 'check_notice_window', target: 'end', type: 'conditional', condition: '{currentContract.end_date} > {TODAY() + currentContract.renewal_notice_days}', label: 'Not yet' },
{ id: 'e4', source: 'check_notice_window', target: 'create_renewal_task', type: 'conditional', condition: 'timestamp(currentContract.end_date) <= daysFromNow(int(currentContract.renewal_notice_days))', label: 'In window' },
{ id: 'e5', source: 'check_notice_window', target: 'end', type: 'conditional', condition: 'timestamp(currentContract.end_date) > daysFromNow(int(currentContract.renewal_notice_days))', label: 'Not yet' },
{ id: 'e6', source: 'create_renewal_task', target: 'notify_owner', type: 'default' },
{ id: 'e7', source: 'notify_owner', target: 'check_auto_renewal', type: 'default' },
{ id: 'e8', source: 'check_auto_renewal', target: 'create_renewal_opp', type: 'conditional', condition: '{currentContract.auto_renewal} == true', label: 'Auto-renew' },
{ id: 'e9', source: 'check_auto_renewal', target: 'end', type: 'conditional', condition: '{currentContract.auto_renewal} != true', label: 'Manual' },
{ id: 'e8', source: 'check_auto_renewal', target: 'create_renewal_opp', type: 'conditional', condition: 'currentContract.auto_renewal == true', label: 'Auto-renew' },
{ id: 'e9', source: 'check_auto_renewal', target: 'end', type: 'conditional', condition: 'currentContract.auto_renewal != true', label: 'Manual' },
{ id: 'e10', source: 'create_renewal_opp', target: 'end', type: 'default' },
],
};
20 changes: 20 additions & 0 deletions src/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export { CaseSlaMonitorFlow } from './case-sla-monitor.flow';
export { OpportunityStagnationFlow } from './opportunity-stagnation.flow';
export { LeadAssignmentFlow } from './lead-assignment.flow';
export { CaseCsatFollowupFlow } from './case-csat-followup.flow';
// Migrated from object workflows[] (removed in 7.7): scheduled status-flips + notifications
export { CampaignCompletionFlow } from './campaign-completion.flow';
export { QuoteExpirationFlow } from './quote-expiration.flow';
export { ContractExpirationFlow } from './contract-expiration.flow';
export { ContactWelcomeFlow } from './contact-welcome.flow';
export { OpportunityWonAlertFlow } from './opportunity-won-alert.flow';
export { TaskUrgentAlertFlow } from './task-urgent-alert.flow';

import { CampaignEnrollmentFlow } from './campaign-enrollment.flow';
import { CaseEscalationFlow } from './case-escalation.flow';
Expand All @@ -27,6 +34,12 @@ import { CaseSlaMonitorFlow } from './case-sla-monitor.flow';
import { OpportunityStagnationFlow } from './opportunity-stagnation.flow';
import { LeadAssignmentFlow } from './lead-assignment.flow';
import { CaseCsatFollowupFlow } from './case-csat-followup.flow';
import { CampaignCompletionFlow } from './campaign-completion.flow';
import { QuoteExpirationFlow } from './quote-expiration.flow';
import { ContractExpirationFlow } from './contract-expiration.flow';
import { ContactWelcomeFlow } from './contact-welcome.flow';
import { OpportunityWonAlertFlow } from './opportunity-won-alert.flow';
import { TaskUrgentAlertFlow } from './task-urgent-alert.flow';

/** All flow definitions as a typed array for defineStack() */
export const allFlows: Flow[] = [
Expand All @@ -42,4 +55,11 @@ export const allFlows: Flow[] = [
OpportunityStagnationFlow,
LeadAssignmentFlow,
CaseCsatFollowupFlow,
// Migrated from object workflows[] (removed in 7.7)
CampaignCompletionFlow,
QuoteExpirationFlow,
ContractExpirationFlow,
ContactWelcomeFlow,
OpportunityWonAlertFlow,
TaskUrgentAlertFlow,
];
8 changes: 4 additions & 4 deletions src/flows/lead-assignment.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const LeadAssignmentFlow: Flow = {
},
{
id: 'check_hot', type: 'decision', label: 'Hot Lead (rating ≥ 4)?',
config: { condition: '{record.rating} >= 4' },
config: { condition: 'record.rating >= 4' },
},

// ── Hot path: 1-day SLA, high-severity alert ───────────────────
Expand All @@ -51,7 +51,7 @@ export const LeadAssignmentFlow: Flow = {
config: {
to: ['sales_manager'],
channels: ['inbox', 'email'],
severity: 'high',
severity: 'warning',
topic: 'lead_routing',
title: 'Hot lead — assign within 24h: {record.full_name}',
body: '{record.full_name} from {record.company} (rating {record.rating}) needs an owner today.',
Expand Down Expand Up @@ -81,8 +81,8 @@ export const LeadAssignmentFlow: Flow = {

edges: [
{ id: 'e1', source: 'start', target: 'check_hot', type: 'default' },
{ id: 'e2', source: 'check_hot', target: 'sla_hot', type: 'conditional', condition: '{record.rating} >= 4', label: 'Hot' },
{ id: 'e3', source: 'check_hot', target: 'sla_std', type: 'conditional', condition: '{record.rating} < 4', label: 'Standard' },
{ id: 'e2', source: 'check_hot', target: 'sla_hot', type: 'conditional', condition: 'record.rating >= 4', label: 'Hot' },
{ id: 'e3', source: 'check_hot', target: 'sla_std', type: 'conditional', condition: 'record.rating < 4', label: 'Standard' },
{ id: 'e4', source: 'sla_hot', target: 'notify_hot', type: 'default' },
{ id: 'e5', source: 'notify_hot', target: 'end', type: 'default' },
{ id: 'e6', source: 'sla_std', target: 'notify_std', type: 'default' },
Expand Down
2 changes: 1 addition & 1 deletion src/flows/opportunity-approval.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const OpportunityApprovalFlow: Flow = {
config: {
to: ['{oppRecord.owner}'],
channels: ['inbox', 'email'],
severity: 'high',
severity: 'warning',
topic: 'opportunity_rejected',
title: 'Deal rejected: {oppRecord.name}',
body: 'Your opportunity {oppRecord.name} was not approved. Review and revise before resubmitting.',
Expand Down
51 changes: 51 additions & 0 deletions src/flows/opportunity-won-alert.flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type * as Automation from '@objectstack/spec/automation';
type Flow = Automation.Flow;

/**
* Large-deal-won alert — record-change flow on opportunity update.
*
* Migrated from the removed `notify_on_large_deal_won` object workflow (7.7
* dropped `workflows[]`). When a deal > $100K is marked closed_won, notify the
* owner and their manager.
*
* NB: `record.amount > 100000` is authored as bare CEL against the (string-
* serialized) currency field — this relies on the 7.7 numeric-hydration fix
* (framework #1534); pre-7.7 it would have needed `double(record.amount)`.
*/
export const OpportunityWonAlertFlow: Flow = {
name: 'opportunity_won_alert',
label: 'Large Deal Won Alert',
description: 'On closed_won opportunities over $100K: notify owner + manager.',
type: 'record_change',
status: 'active',
variables: [],
nodes: [
{
id: 'start', type: 'start', label: 'Start (opp updated)',
config: {
objectName: 'crm_opportunity',
triggerType: 'record-after-update',
condition: 'record.stage == "closed_won" && record.amount > 100000',
},
},
{
id: 'notify_management', type: 'notify', label: 'Notify Management',
config: {
to: ['{record.owner}', '{record.owner.manager}'],
channels: ['inbox', 'email'],
severity: 'info',
topic: 'large_deal_won',
title: 'Large deal won: {record.name}',
body: '{record.name} closed at {record.amount}. Congratulations to the team.',
actionUrl: '/crm_opportunity/{record.id}',
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'notify_management', type: 'default' },
{ id: 'e2', source: 'notify_management', target: 'end', type: 'default' },
],
};
Loading
Loading