diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 16a62f05cd..e4915e3000 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -7,10 +7,8 @@ import * as views from './src/views/index.js'; import * as apps from './src/apps/index.js'; import * as dashboards from './src/dashboards/index.js'; import * as datasets from './src/datasets/index.js'; -import * as reports from './src/reports/index.js'; import * as pages from './src/pages/index.js'; import * as actions from './src/actions/index.js'; -import * as emails from './src/emails/index.js'; import { allHooks } from './src/hooks/index.js'; import { allFlows } from './src/flows/index.js'; import { @@ -24,29 +22,19 @@ import { WonDealActivitySharingRule, } from './src/security/index.js'; import { CrmSeedData } from './src/data/index.js'; -import { LeadCsvImportMapping, ContactJsonSyncMapping } from './src/data/crm-mappings.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; import { CrmTranslationBundle } from './src/translations/crm.translation.js'; -import { ContactExtension } from './src/extensions/contact.extension.js'; -import { CustomerPortal } from './src/portals/customer.portal.js'; -import { CrmLightTheme, CrmDarkTheme } from './src/themes/crm.theme.js'; -import { LeadScoringJob, PipelineReportJob, RenewalSweepJob } from './src/jobs/crm-jobs.js'; -import { - PipelineSummaryEndpoint, - LeadConvertEndpoint, - MarketingWebhookEndpoint, -} from './src/api/crm-endpoints.js'; -import { OpportunityChangedWebhook, DealWonSlackWebhook } from './src/webhooks/crm-webhooks.js'; -import { PipelineCube, LeadFunnelCube } from './src/analytics/crm.cube.js'; -import { HubSpotConnector, SlackConnector } from './src/connectors/crm-connectors.js'; /** - * CRM example — exercises the full metadata loading pipeline with at - * least one record of every form-bearing metadata type so the Studio - * metadata-admin UI can be developed and validated against real data. + * CRM example — a MINIMAL, realistic relational bundle that smoke-tests the + * metadata application loading pipeline: objects/relationships → views → + * app → dashboard (dataset-backed) → hook → one screen-flow wizard → seed. + * Deliberately small so `pnpm dev:crm` boots fast for backend debugging. * - * For a full enterprise reference (10+ objects, RAG, sharing rules, - * etc.) see https://github.com/objectstack-ai/hotcrm + * NOT a feature showcase: capability breadth (cubes, extensions, apis, + * webhooks, portals, themes, reports, jobs, emails, automation variety) + * lives in examples/app-showcase, whose coverage manifest enforces it. + * For a full enterprise reference see https://github.com/objectstack-ai/hotcrm */ export default defineStack({ manifest: { @@ -58,12 +46,10 @@ export default defineStack({ description: 'Minimal CRM workspace used by the framework to validate the metadata loading pipeline end-to-end.', }, - // Auto-resolved by the CLI; `ui` enables the Studio shell, `automation` loads - // AutomationServicePlugin + node packs so screen flows can execute, and - // `approvals` loads ApprovalsServicePlugin so the `approval` flow node is - // contributed to the engine (ADR-0019) — required for the discount-approval - // flow to compile/register and to surface the node in the designer palette. - requires: ['ui', 'automation', 'approvals'], + // Auto-resolved by the CLI; `ui` enables the Studio shell, `automation` + // loads AutomationServicePlugin + node packs so the convert-lead screen + // flow can execute. + requires: ['ui', 'automation'], // Infrastructure datasources: [CrmDatasource, CrmAnalyticsDatasource], @@ -85,28 +71,22 @@ export default defineStack({ // Data objects: Object.values(objects), - objectExtensions: [ContactExtension], // UI apps: Object.values(apps), - portals: [CustomerPortal], views: Object.values(views), pages: Object.values(pages), dashboards: Object.values(dashboards), datasets: Object.values(datasets), - reports: Object.values(reports), actions: Object.values(actions), - themes: [CrmLightTheme, CrmDarkTheme], // Logic hooks: allHooks, - // ADR-0020: `workflows` retired — record state machines are now a + // ADR-0020: `workflows` retired — record state machines are a // `state_machine` validation rule on the object (see - // src/objects/opportunity.object.ts) and side-effecting automation is - // modelled as Flows (high-value-deal, stale-opportunity in allFlows). + // src/objects/opportunity.object.ts). One flow only: the convert-lead + // screen wizard the smoke test drives. flows: allFlows, - jobs: [LeadScoringJob, PipelineReportJob, RenewalSweepJob], - emailTemplates: Object.values(emails), // Security roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole], @@ -117,17 +97,6 @@ export default defineStack({ WonDealActivitySharingRule, ], - // API - apis: [PipelineSummaryEndpoint, LeadConvertEndpoint, MarketingWebhookEndpoint], - webhooks: [OpportunityChangedWebhook, DealWonSlackWebhook], - - // Data Extensions - mappings: [LeadCsvImportMapping, ContactJsonSyncMapping], - analyticsCubes: [PipelineCube, LeadFunnelCube], - - // Integrations - connectors: [HubSpotConnector, SlackConnector], - // Seed data data: CrmSeedData, }); diff --git a/examples/app-crm/src/actions/index.ts b/examples/app-crm/src/actions/index.ts index 9490e28e5f..0957b456b8 100644 --- a/examples/app-crm/src/actions/index.ts +++ b/examples/app-crm/src/actions/index.ts @@ -1,4 +1,3 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { ConvertLeadAction } from './convert-lead.action.js'; -export { ParkLeadAction } from './park-lead.action.js'; diff --git a/examples/app-crm/src/actions/park-lead.action.ts b/examples/app-crm/src/actions/park-lead.action.ts deleted file mode 100644 index 0f4a30ca04..0000000000 --- a/examples/app-crm/src/actions/park-lead.action.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineAction } from '@objectstack/spec/ui'; - -/** - * Row-level action on crm_lead — reassign the lead's owner. - * - * Demonstrates the `undoable` action affordance: it's a single-record field - * update (`assigned_to`), so the runtime captures the prior owner and the - * success toast offers an "Undo" that restores it (backed by the client - * UndoManager — Ctrl+Z works too). Prompts for the new owner via one param - * (pre-filled with "Triage Queue"). - */ -export const ParkLeadAction = defineAction({ - name: 'crm_park_lead', - label: 'Reassign Lead', - icon: 'UserPlus', - objectName: 'crm_lead', - // `type: 'api'` with a non-URL target routes to the console runtime's generic - // `dataSource.update` path (the row id comes from the list_item row record). - type: 'api', - target: 'crm_lead', - // From the list row AND the record header — both runtimes drive param - // collection, the dataSource.update, and the Undo toast. - locations: ['list_item', 'record_header'], - // Conditional disable (CEL): a converted lead is locked — the action shows but - // greys out, rather than disappearing. Demonstrates `disabled` (greys) vs - // `visible` (hides). Evaluated against the record on every surface. - disabled: 'record.status == "converted"', - params: [ - { field: 'assigned_to', label: 'Reassign to', defaultValue: 'Triage Queue', required: true }, - ], - undoable: true, - successMessage: 'Lead reassigned.', -}); diff --git a/examples/app-crm/src/analytics/crm.cube.ts b/examples/app-crm/src/analytics/crm.cube.ts deleted file mode 100644 index 766f9cc5ab..0000000000 --- a/examples/app-crm/src/analytics/crm.cube.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineCube } from '@objectstack/spec/data'; - -/** - * Opportunity Pipeline Cube — revenue metrics broken down by stage, - * owner, and account for the CRM sales dashboard. - */ -export const PipelineCube = defineCube({ - name: 'crm_pipeline', - title: 'CRM Pipeline', - description: 'Revenue and deal-count analytics across the sales pipeline.', - sql: 'crm_opportunity', - measures: { - count: { - name: 'count', - label: 'Deal Count', - type: 'count', - sql: '*', - }, - total_amount: { - name: 'total_amount', - label: 'Total Pipeline Value', - type: 'sum', - sql: 'amount', - format: 'currency', - }, - avg_amount: { - name: 'avg_amount', - label: 'Average Deal Size', - type: 'avg', - sql: 'amount', - format: 'currency', - }, - win_rate: { - name: 'win_rate', - label: 'Win Rate (%)', - type: 'number', - sql: "SUM(CASE WHEN stage = 'closed_won' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)", - format: 'percent', - }, - }, - dimensions: { - stage: { - name: 'stage', - label: 'Pipeline Stage', - type: 'string', - sql: 'stage', - }, - close_date: { - name: 'close_date', - label: 'Close Date', - type: 'time', - sql: 'close_date', - }, - owner: { - name: 'owner', - label: 'Owner', - type: 'string', - sql: 'owner_id', - }, - }, - joins: { - crm_account: { - name: 'crm_account', - relationship: 'many_to_one', - sql: '${crm_pipeline}.account_id = ${crm_account}.id', - }, - }, - refreshKey: { - every: '1 hour', - }, - public: false, -}); - -/** - * Lead funnel cube — conversion metrics from lead to opportunity. - */ -export const LeadFunnelCube = defineCube({ - name: 'crm_lead_funnel', - title: 'CRM Lead Funnel', - description: 'Lead volume and conversion rate analytics.', - sql: 'crm_lead', - measures: { - count: { - name: 'count', - label: 'Lead Count', - type: 'count', - sql: '*', - }, - converted_count: { - name: 'converted_count', - label: 'Converted Leads', - type: 'count', - sql: 'converted_opportunity_id', - }, - }, - dimensions: { - status: { - name: 'status', - label: 'Lead Status', - type: 'string', - sql: 'status', - }, - source: { - name: 'source', - label: 'Lead Source', - type: 'string', - sql: 'source', - }, - created_at: { - name: 'created_at', - label: 'Created At', - type: 'time', - sql: 'created_at', - }, - }, - refreshKey: { - every: '30 minutes', - }, - public: false, -}); diff --git a/examples/app-crm/src/api/crm-endpoints.ts b/examples/app-crm/src/api/crm-endpoints.ts deleted file mode 100644 index fbd1797054..0000000000 --- a/examples/app-crm/src/api/crm-endpoints.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { ApiEndpoint } from '@objectstack/spec/api'; - -/** - * Custom REST endpoint — exposes pipeline summary metrics. - * Backed by a flow (lead_qualification_conversion) that aggregates stage data. - */ -export const PipelineSummaryEndpoint: ApiEndpoint = { - name: 'crm_pipeline_summary', - path: '/api/v1/crm/pipeline-summary', - method: 'GET', - summary: 'Pipeline summary by stage', - description: 'Returns opportunity count and total value grouped by stage.', - type: 'object_operation', - target: 'crm_opportunity', - objectParams: { - object: 'crm_opportunity', - operation: 'find', - }, - authRequired: true, - cacheTtl: 60, -}; - -/** - * Lead conversion endpoint — triggers the qualification flow via HTTP. - */ -export const LeadConvertEndpoint: ApiEndpoint = { - name: 'crm_lead_convert', - path: '/api/v1/crm/leads/:id/convert', - method: 'POST', - summary: 'Convert a lead to an opportunity', - type: 'flow', - target: 'lead_qualification_conversion', - inputMapping: [ - { source: 'params.id', target: 'leadId' }, - { source: 'body.ownerId', target: 'ownerId' }, - ], - authRequired: true, -}; - -/** - * Public webhook receiver — inbound events from external marketing tools. - * No auth required (HMAC verified at the webhook layer instead). - */ -export const MarketingWebhookEndpoint: ApiEndpoint = { - name: 'crm_marketing_webhook', - path: '/api/v1/crm/marketing/events', - method: 'POST', - summary: 'Receive marketing automation events', - type: 'flow', - target: 'lead_qualification_conversion', - authRequired: false, -}; diff --git a/examples/app-crm/src/connectors/crm-connectors.ts b/examples/app-crm/src/connectors/crm-connectors.ts deleted file mode 100644 index 3cf9b6c42f..0000000000 --- a/examples/app-crm/src/connectors/crm-connectors.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineConnector } from '@objectstack/spec/integration'; - -/** - * HubSpot connector — sync contacts and deals bi-directionally. - * Uses OAuth2 for authentication; actual credentials come from environment. - */ -export const HubSpotConnector = defineConnector({ - name: 'hubspot_crm', - label: 'HubSpot CRM', - type: 'saas', - description: 'Bi-directional sync of contacts, companies, and deals with HubSpot.', - icon: 'hubspot', - authentication: { - type: 'oauth2', - authorizationUrl: 'https://app.hubspot.com/oauth/authorize', - tokenUrl: 'https://api.hubapi.com/oauth/v1/token', - clientId: 'env:HUBSPOT_CLIENT_ID', - clientSecret: 'env:HUBSPOT_CLIENT_SECRET', - scopes: ['contacts', 'crm.objects.deals.read', 'crm.objects.deals.write'], - }, - actions: [ - { - key: 'create_contact', - label: 'Create Contact', - description: 'Create a new contact in HubSpot', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string' }, - firstname: { type: 'string' }, - lastname: { type: 'string' }, - }, - required: ['email'], - }, - }, - { - key: 'update_deal', - label: 'Update Deal', - description: 'Update an existing deal in HubSpot', - inputSchema: { - type: 'object', - properties: { - dealId: { type: 'string' }, - dealstage: { type: 'string' }, - amount: { type: 'number' }, - }, - required: ['dealId'], - }, - }, - ], - triggers: [ - { - key: 'contact_created', - label: 'Contact Created', - type: 'webhook', - description: 'Fires when a contact is created in HubSpot', - }, - { - key: 'deal_stage_changed', - label: 'Deal Stage Changed', - type: 'polling', - interval: 300, - description: 'Polls every 5 minutes for deal stage changes', - }, - ], - syncConfig: { - direction: 'bidirectional', - schedule: '0 * * * *', - conflictResolution: 'source_wins', - batchSize: 500, - }, - rateLimitConfig: { - maxRequests: 100, - windowSeconds: 10, - strategy: 'sliding_window', - }, - retryConfig: { - strategy: 'exponential_backoff', - maxAttempts: 3, - initialDelayMs: 1000, - maxDelayMs: 30000, - }, - connectionTimeoutMs: 10000, - requestTimeoutMs: 30000, - status: 'inactive', - enabled: true, -}); - -/** - * Slack connector — post notifications to channels. - */ -export const SlackConnector = defineConnector({ - name: 'slack_notifications', - label: 'Slack', - type: 'api', - description: 'Post deal-win and alert notifications to Slack channels.', - icon: 'slack', - authentication: { - type: 'bearer', - token: 'env:SLACK_BOT_TOKEN', - }, - actions: [ - { - key: 'post_message', - label: 'Post Message', - description: 'Post a message to a Slack channel', - inputSchema: { - type: 'object', - properties: { - channel: { type: 'string' }, - text: { type: 'string' }, - }, - required: ['channel', 'text'], - }, - }, - ], - rateLimitConfig: { - maxRequests: 50, - windowSeconds: 60, - strategy: 'token_bucket', - }, - status: 'inactive', - enabled: true, -}); diff --git a/examples/app-crm/src/data/crm-mappings.ts b/examples/app-crm/src/data/crm-mappings.ts deleted file mode 100644 index 66a17731fd..0000000000 --- a/examples/app-crm/src/data/crm-mappings.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineMapping } from '@objectstack/spec/data'; - -/** - * CSV import mapping for bulk lead upload. - * Maps common CRM export column names to crm_lead fields. - */ -export const LeadCsvImportMapping = defineMapping({ - name: 'csv_import_leads', - label: 'CSV Import: Leads', - sourceFormat: 'csv', - targetObject: 'crm_lead', - mode: 'upsert', - upsertKey: ['email'], - fieldMapping: [ - { source: 'First Name', target: 'first_name', transform: 'none' }, - { source: 'Last Name', target: 'last_name', transform: 'none' }, - { source: 'Email', target: 'email', transform: 'none' }, - { source: 'Phone', target: 'phone', transform: 'none' }, - { source: 'Company', target: 'company', transform: 'none' }, - { - source: 'Lead Status', - target: 'status', - transform: 'map', - params: { - valueMap: { - New: 'new', - 'Working': 'contacted', - Qualified: 'qualified', - 'Unqualified': 'disqualified', - Converted: 'converted', - }, - }, - }, - { - source: 'Lead Source', - target: 'source', - transform: 'map', - params: { - valueMap: { - 'Web': 'web', - 'Email Campaign': 'email_campaign', - 'Cold Call': 'cold_call', - 'Referral': 'referral', - 'Trade Show': 'trade_show', - }, - }, - }, - { source: 'Lead Score', target: 'lead_score', transform: 'none' }, - ], - errorPolicy: 'skip', - batchSize: 500, -}); - -/** - * JSON import mapping for contact sync from external systems (HubSpot, etc.). - */ -export const ContactJsonSyncMapping = defineMapping({ - name: 'json_sync_contacts', - label: 'JSON Sync: Contacts from HubSpot', - sourceFormat: 'json', - targetObject: 'crm_contact', - mode: 'upsert', - upsertKey: ['email'], - fieldMapping: [ - { source: 'properties.firstname', target: 'first_name', transform: 'none' }, - { source: 'properties.lastname', target: 'last_name', transform: 'none' }, - { source: 'properties.email', target: 'email', transform: 'none' }, - { source: 'properties.phone', target: 'phone', transform: 'none' }, - { source: 'properties.jobtitle', target: 'title', transform: 'none' }, - ], - errorPolicy: 'skip', - batchSize: 250, -}); diff --git a/examples/app-crm/src/emails/deal-won.email.ts b/examples/app-crm/src/emails/deal-won.email.ts deleted file mode 100644 index 39bac0b18a..0000000000 --- a/examples/app-crm/src/emails/deal-won.email.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineEmailTemplateDefinition } from '@objectstack/spec/system'; - -/** - * Sent when an opportunity moves to Closed Won. - * Referenced by the `notify_owner_deal_won` workflow action. - */ -export const DealWonEmail = defineEmailTemplateDefinition({ - name: 'crm.deal_won', - label: 'Deal Won — Owner Congrats', - category: 'workflow', - locale: 'en-US', - subject: 'Congratulations — {{opportunity.name}} closed!', - bodyHtml: ` -
-

Great news!

-

Hi {{user.name}},

-

The opportunity {{opportunity.name}} for {{account.name}} just closed at \${{opportunity.amount}}.

-

Nice work! 🎉

-
-`, - bodyText: `Hi {{user.name}}, - -The opportunity {{opportunity.name}} for {{account.name}} just closed at \${{opportunity.amount}}. - -Nice work!`, - variables: [ - { name: 'user.name', type: 'string', required: true, description: 'Opportunity owner' }, - { name: 'opportunity.name', type: 'string', required: true }, - { name: 'opportunity.amount', type: 'number', required: true, description: 'Closed amount in USD' }, - { name: 'account.name', type: 'string', required: true }, - ], - active: true, - description: 'Internal congrats email fired by the High-Value Deal workflow when stage = Closed Won.', -}); diff --git a/examples/app-crm/src/emails/index.ts b/examples/app-crm/src/emails/index.ts deleted file mode 100644 index 2293a35685..0000000000 --- a/examples/app-crm/src/emails/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -export { DealWonEmail } from './deal-won.email.js'; -export { WelcomeEmail } from './welcome.email.js'; -export { LeadFollowUpEmail } from './lead-follow-up.email.js'; diff --git a/examples/app-crm/src/emails/lead-follow-up.email.ts b/examples/app-crm/src/emails/lead-follow-up.email.ts deleted file mode 100644 index 43d9fd94d2..0000000000 --- a/examples/app-crm/src/emails/lead-follow-up.email.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineEmailTemplateDefinition } from '@objectstack/spec/system'; - -/** - * Follow-up nudge sent by the Stale Opportunity workflow when no - * activity has been logged on a Lead for the configured threshold. - */ -export const LeadFollowUpEmail = defineEmailTemplateDefinition({ - name: 'crm.lead_followup', - label: 'Lead — Follow-Up Reminder', - category: 'notification', - locale: 'en-US', - subject: 'Reminder: follow up on {{lead.name}}', - bodyHtml: ` -
-

Time to follow up

-

Hi {{owner.name}},

-

Lead {{lead.name}} ({{lead.company}}) has had no activity for {{days_idle}} days.

-

Open the lead in CRM and log the next action to keep your pipeline healthy.

-

View lead

-
-`, - bodyText: `Hi {{owner.name}}, - -Lead {{lead.name}} ({{lead.company}}) has had no activity for {{days_idle}} days. - -Follow up: {{lead_url}}`, - variables: [ - { name: 'owner.name', type: 'string', required: true }, - { name: 'lead.name', type: 'string', required: true }, - { name: 'lead.company', type: 'string', required: false }, - { name: 'days_idle', type: 'number', required: true }, - { name: 'lead_url', type: 'url', required: true }, - ], - active: true, - description: 'Internal reminder fired by the Stale Opportunity workflow.', -}); diff --git a/examples/app-crm/src/emails/welcome.email.ts b/examples/app-crm/src/emails/welcome.email.ts deleted file mode 100644 index 77282fce5a..0000000000 --- a/examples/app-crm/src/emails/welcome.email.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineEmailTemplateDefinition } from '@objectstack/spec/system'; - -/** - * Welcome email sent to a Contact after it's added to the CRM. - * Demonstrates marketing-category templates with a clear CTA. - */ -export const WelcomeEmail = defineEmailTemplateDefinition({ - name: 'crm.welcome', - label: 'Welcome — New Contact', - category: 'marketing', - locale: 'en-US', - subject: 'Welcome to {{account.name}}, {{contact.first_name}}!', - bodyHtml: ` -
-

Welcome aboard 👋

-

Hi {{contact.first_name}},

-

Thanks for connecting with {{account.name}}. Your account manager {{owner.name}} will be in touch shortly.

-

Open your customer portal

-

Or copy this link: {{portal_url}}

-
-`, - bodyText: `Hi {{contact.first_name}}, - -Thanks for connecting with {{account.name}}. Your account manager {{owner.name}} will be in touch shortly. - -Open your portal: {{portal_url}}`, - variables: [ - { name: 'contact.first_name', type: 'string', required: true }, - { name: 'account.name', type: 'string', required: true }, - { name: 'owner.name', type: 'string', required: true, description: 'Assigned account manager' }, - { name: 'portal_url', type: 'url', required: true, description: 'Customer portal link' }, - ], - fromOverride: { name: 'Acme Sales', address: 'sales@acme.example' }, - replyTo: 'support@acme.example', - active: true, - description: 'Marketing welcome email sent on contact creation.', -}); diff --git a/examples/app-crm/src/extensions/contact.extension.ts b/examples/app-crm/src/extensions/contact.extension.ts deleted file mode 100644 index 1aa55ce5ed..0000000000 --- a/examples/app-crm/src/extensions/contact.extension.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineObjectExtension } from '@objectstack/spec/data'; - -/** - * Extends the built-in crm_contact object with social-media fields. - * Demonstrates ObjectExtension — additive fields without re-declaring the - * whole object schema. - */ -export const ContactExtension = defineObjectExtension({ - extend: 'crm_contact', - label: 'Contact (CRM Extended)', - fields: { - linkedin_url: { - name: 'linkedin_url', - label: 'LinkedIn URL', - type: 'url', - }, - twitter_handle: { - name: 'twitter_handle', - label: 'Twitter / X Handle', - type: 'text', - maxLength: 64, - }, - preferred_channel: { - name: 'preferred_channel', - label: 'Preferred Contact Channel', - type: 'select', - options: [ - { value: 'email', label: 'Email' }, - { value: 'phone', label: 'Phone' }, - { value: 'linkedin', label: 'LinkedIn' }, - { value: 'whatsapp', label: 'WhatsApp' }, - ], - }, - }, - priority: 210, -}); diff --git a/examples/app-crm/src/flows/discount-approval.flow.ts b/examples/app-crm/src/flows/discount-approval.flow.ts deleted file mode 100644 index 41fd145012..0000000000 --- a/examples/app-crm/src/flows/discount-approval.flow.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Flow } from '@objectstack/spec/automation'; - -/** - * Discount approval — ADR-0019 approval-as-flow-node. - * - * What used to be a standalone two-step approval *process* is now an ordinary - * autolaunched flow with two `approval` nodes. The flow suspends on each - * approval and resumes down the matching `approve` / `reject` edge: - * - * start → manager_review ──approve──▶ finance_review ──approve──▶ end - * └─reject──▶ rejected └─reject──▶ rejected - * - * Finance only signs off when the discount exceeds 30% — that gate is just a - * decision node on the approve edge out of the manager step. - */ -export const DiscountApprovalFlow: Flow = { - name: 'crm_discount_approval', - label: 'Opportunity Discount Approval', - description: 'Two-step approval for opportunities with significant discounts.', - type: 'autolaunched', - - nodes: [ - { - id: 'start', - type: 'start', - label: 'On Discount Above Threshold', - config: { - objectName: 'crm_opportunity', - triggerType: 'record-after-update', - condition: 'discount_percent > 20', - }, - }, - { - id: 'manager_review', - type: 'approval', - label: 'Manager Review', - config: { - approvers: [{ type: 'role', value: 'sales_manager' }], - behavior: 'first_response', - lockRecord: true, - approvalStatusField: 'approval_status', - }, - }, - { - id: 'needs_finance', - type: 'decision', - label: 'Discount Above 30%?', - config: { condition: 'discount_percent > 30' }, - }, - { - id: 'finance_review', - type: 'approval', - label: 'Finance Review', - config: { - approvers: [{ type: 'role', value: 'finance_approver' }], - behavior: 'unanimous', - lockRecord: true, - approvalStatusField: 'approval_status', - }, - }, - { id: 'approved', type: 'end', label: 'Approved' }, - { id: 'rejected', type: 'end', label: 'Rejected' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'manager_review' }, - { id: 'e2', source: 'manager_review', target: 'needs_finance', label: 'approve' }, - { id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' }, - { id: 'e4', source: 'needs_finance', target: 'finance_review', label: 'true' }, - { id: 'e5', source: 'needs_finance', target: 'approved', label: 'false' }, - { id: 'e6', source: 'finance_review', target: 'approved', label: 'approve' }, - { id: 'e7', source: 'finance_review', target: 'rejected', label: 'reject' }, - ], -}; diff --git a/examples/app-crm/src/flows/high-value-deal.flow.ts b/examples/app-crm/src/flows/high-value-deal.flow.ts deleted file mode 100644 index e355a09c45..0000000000 --- a/examples/app-crm/src/flows/high-value-deal.flow.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Flow } from '@objectstack/spec/automation'; - -/** - * High-Value Deal alert — record-triggered flow. - * - * Migrated from the legacy `workflow` metadata type (retired in ADR-0020, - * which reclaims the runtime FSM as the `state_machine` validation rule on - * the object and folds side-effecting automation into Flow). The original - * rule fired when a `crm_opportunity` crossed the $100k threshold and - * notified sales managers; that side effect is exactly a record-triggered - * Flow. - * - * The lifecycle/transition aspect (open → high_value → won/lost) is no - * longer modelled here — record state transitions belong on the object as a - * `state_machine` validation rule (see opportunity.object.ts). This Flow - * carries only the notification side effect. - */ -export const HighValueDealFlow: Flow = { - name: 'high_value_deal_alert', - label: 'Notify on High-Value Deal', - description: 'Notifies sales managers when an opportunity amount crosses the $100k threshold.', - type: 'autolaunched', - - nodes: [ - { - id: 'start', - type: 'start', - label: 'On Opportunity Update', - config: { - objectName: 'crm_opportunity', - triggerType: 'record-after-update', - // Fire only on the upward crossing of the threshold, not on every - // save of an already-high-value deal. - condition: 'amount > 100000 && (previous.amount == null || previous.amount <= 100000)', - }, - }, - { - id: 'notify_managers', - type: 'script', - label: 'Notify Sales Managers', - config: { - actionType: 'email', - inputs: { - to: '{record.owner_manager_email}', - subject: '💰 High-Value Deal: {record.name}', - template: 'high_value_deal_alert', - }, - }, - }, - { id: 'end', type: 'end', label: 'End' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'notify_managers' }, - { id: 'e2', source: 'notify_managers', target: 'end' }, - ], -}; diff --git a/examples/app-crm/src/flows/index.ts b/examples/app-crm/src/flows/index.ts index 7421ebbaa2..0e0a33162c 100644 --- a/examples/app-crm/src/flows/index.ts +++ b/examples/app-crm/src/flows/index.ts @@ -1,23 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { OpportunityWonFlow } from './opportunity-won.flow.js'; -import { LeadQualificationFlow } from './lead-qualification.flow.js'; -import { RenewalReminderFlow } from './renewal-reminder.flow.js'; +// One flow — the screen-flow wizard the smoke test drives. Automation +// breadth (approvals, schedules, connectors, subflows, …) is the +// showcase's job (examples/app-showcase/src/automation/flows/). import { ConvertLeadScreenFlow } from './convert-lead.flow.js'; -import { DiscountApprovalFlow } from './discount-approval.flow.js'; -// ADR-0020: side-effecting automation migrated off the retired `workflow` -// metadata type into record-triggered / scheduled Flows. -import { HighValueDealFlow } from './high-value-deal.flow.js'; -import { StaleOpportunityFlow } from './stale-opportunity.flow.js'; export { ConvertLeadScreenFlow } from './convert-lead.flow.js'; -export const allFlows = [ - OpportunityWonFlow, - LeadQualificationFlow, - RenewalReminderFlow, - ConvertLeadScreenFlow, - DiscountApprovalFlow, - HighValueDealFlow, - StaleOpportunityFlow, -]; +export const allFlows = [ConvertLeadScreenFlow]; diff --git a/examples/app-crm/src/flows/lead-qualification.flow.ts b/examples/app-crm/src/flows/lead-qualification.flow.ts deleted file mode 100644 index c8e0ca5d10..0000000000 --- a/examples/app-crm/src/flows/lead-qualification.flow.ts +++ /dev/null @@ -1,375 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Flow } from '@objectstack/spec/automation'; - -/** - * Lead Qualification & Conversion Flow - * - * Exercises the full breadth of the Flow node type repertoire: - * • assignment — initialise scoring variables - * • get_record — fetch the related Account - * • http — external enrichment API (with fault edge → error handler) - * • script — calculate a composite lead score - * • decision — branch on score threshold (conditional + isDefault edges) - * • parallel_gateway — AND-split: notify rep AND create Opportunity simultaneously - * • join_gateway — AND-join: wait for both branches before continuing - * • create_record — create the derived Opportunity - * • update_record — mark lead converted / disqualified - * • wait — pause 24 h for a prospect-response timer event - * • subflow — escalate to manager if no follow-up after the wait - * • end — multiple terminal nodes for distinct outcomes - * - * Flow-level config exercised: - * • variables — typed input/output variable declarations - * • errorHandling — retry with exponential back-off + fallback node - * • status / runAs / version - */ -export const LeadQualificationFlow: Flow = { - name: 'lead_qualification_conversion', - label: 'Lead Qualification & Conversion', - description: - 'Qualifies an inbound lead through scoring and enrichment, then converts it to an Opportunity via parallel processing, a timed wait, and optional manager escalation.', - type: 'record_change', - status: 'active', - version: 2, - runAs: 'system', - - // ── Typed variable declarations ────────────────────────────────────────── - variables: [ - { name: 'lead_score', type: 'number', isInput: false, isOutput: false }, - { name: 'enrichment_data', type: 'object', isInput: false, isOutput: false }, - { name: 'account_data', type: 'object', isInput: false, isOutput: false }, - { name: 'new_opportunity', type: 'object', isInput: false, isOutput: false }, - { name: 'opportunity_id', type: 'text', isInput: false, isOutput: true }, - { name: 'qualified', type: 'boolean', isInput: false, isOutput: true }, - ], - - // ── Flow-level error handling (retry with exponential back-off) ────────── - errorHandling: { - strategy: 'retry', - maxRetries: 3, - retryDelayMs: 2000, - backoffMultiplier: 2, - maxRetryDelayMs: 30000, - jitter: true, - fallbackNodeId: 'error_handler', - }, - - // ── Node graph ─────────────────────────────────────────────────────────── - nodes: [ - - // ── 1. Start — fires when lead status transitions to "qualifying" ───── - { - id: 'start', - type: 'start', - label: 'Lead Status → Qualifying', - config: { - objectName: 'crm_lead', - triggerType: 'record-after-update', - condition: 'status == "qualifying" && previous.status != "qualifying"', - }, - position: { x: 400, y: 0 }, - }, - - // ── 2. Assignment — initialise variables before any branching ───────── - { - id: 'init_vars', - type: 'assignment', - label: 'Initialise Scoring Variables', - config: { - assignments: [ - { variable: 'lead_score', value: 0 }, - { variable: 'qualified', value: false }, - { variable: 'enrichment_data', value: null }, - ], - }, - position: { x: 400, y: 120 }, - }, - - // ── 3. Get Record — fetch the linked Account for revenue scoring ─────── - { - id: 'get_account', - type: 'get_record', - label: 'Fetch Account Details', - config: { - objectName: 'crm_account', - filter: { id: '{record.account}' }, - outputVariable: 'account_data', - }, - outputSchema: { - account_data: { type: 'object', description: 'Account record' }, - }, - position: { x: 400, y: 240 }, - }, - - // ── 4. HTTP Request — call an external enrichment service ───────────── - // A fault edge connects this node to error_handler if it fails. - { - id: 'enrich_lead', - type: 'http', - label: 'Enrich Lead (External API)', - config: { - method: 'POST', - url: 'https://api.enrichment-service.example/leads/enrich', - headers: { - 'Content-Type': 'application/json', - 'X-Api-Key': '{env.ENRICHMENT_API_KEY}', - }, - body: { - email: '{record.email}', - company: '{record.company}', - }, - outputVariable: 'enrichment_data', - }, - outputSchema: { - enrichment_data: { type: 'object', description: 'Enrichment API response' }, - }, - timeoutMs: 10000, - position: { x: 400, y: 360 }, - }, - - // ── 5. Script — composite lead score from enrichment + account data ──── - { - id: 'score_lead', - type: 'script', - label: 'Calculate Lead Score', - config: { - script: ` - let score = 0; - // Source weighting - const sourceBonus = { referral: 30, event: 20, web: 10, partner: 25 }; - score += sourceBonus[record.source] ?? 5; - // Company size from enrichment (may be undefined if enrichment failed) - const employees = enrichment_data?.employee_count ?? 0; - if (employees > 500) score += 25; - else if (employees > 100) score += 15; - else if (employees > 20) score += 8; - // Existing account relationship bonus - if (account_data?.annual_revenue > 5_000_000) score += 20; - else if (account_data?.annual_revenue > 1_000_000) score += 10; - // Title / seniority - const title = (record.title ?? '').toLowerCase(); - if (title.includes('vp') || title.includes('director') || title.includes('chief')) score += 15; - else if (title.includes('manager') || title.includes('head')) score += 10; - variables.lead_score = Math.min(score, 100); - variables.qualified = score >= 60; - `, - outputVariables: ['lead_score', 'qualified'], - }, - outputSchema: { - lead_score: { type: 'number', description: 'Calculated score 0–100' }, - qualified: { type: 'boolean', description: 'Whether score meets threshold' }, - }, - position: { x: 400, y: 480 }, - }, - - // ── 6. Decision — route on qualification threshold ──────────────────── - { - id: 'decide_qualification', - type: 'decision', - label: 'Lead Score ≥ 60?', - config: { - conditions: [ - { label: 'Qualified', expression: 'lead_score >= 60' }, - { label: 'Not Qualified', expression: 'true' }, - ], - }, - position: { x: 400, y: 600 }, - }, - - // ── 7a. Disqualification path ───────────────────────────────────────── - { - id: 'update_lead_disqualified', - type: 'update_record', - label: 'Mark Lead Disqualified', - config: { - objectName: 'crm_lead', - recordId: '{record.id}', - data: { - status: 'disqualified', - notes: 'Auto-disqualified: Lead score {lead_score}/100 below threshold. Enrichment source: {enrichment_data.company_size}.', - }, - }, - position: { x: 0, y: 720 }, - }, - - // ── 7b. Parallel Gateway — AND-split (notify + create simultaneously) ─ - { - id: 'parallel_split', - type: 'parallel_gateway', - label: 'Notify & Create Opportunity (Parallel)', - position: { x: 600, y: 720 }, - }, - - // ── 8a. Branch A — email notification to the assigned sales rep ──────── - { - id: 'notify_sales_rep', - type: 'script', - label: 'Send Qualification Alert to Rep', - config: { - actionType: 'email', - inputs: { - to: '{record.assigned_to}', - subject: '🎯 Lead Qualified: {record.name} (Score: {lead_score}/100)', - template: 'lead_qualified_email', - context: { - lead_name: '{record.name}', - company: '{record.company}', - lead_score: '{lead_score}', - }, - }, - }, - position: { x: 400, y: 840 }, - }, - - // ── 8b. Branch B — create Opportunity record ─────────────────────────── - { - id: 'create_opportunity', - type: 'create_record', - label: 'Create Opportunity from Lead', - config: { - objectName: 'crm_opportunity', - data: { - name: '{record.company} — Converted Lead: {record.name}', - account: '{record.account}', - stage: 'prospecting', - amount: 0, - probability: 20, - close_date: '{daysFromNow(90)}', - }, - outputVariable: 'new_opportunity', - }, - outputSchema: { - new_opportunity: { type: 'object', description: 'Newly created opportunity' }, - }, - position: { x: 800, y: 840 }, - }, - - // ── 9. Join Gateway — AND-join: wait for both branches ──────────────── - { - id: 'parallel_join', - type: 'join_gateway', - label: 'Wait for Both Branches', - position: { x: 600, y: 960 }, - }, - - // ── 10. Update Lead — set converted state with opportunity link ──────── - { - id: 'update_lead_converted', - type: 'update_record', - label: 'Mark Lead Converted', - config: { - objectName: 'crm_lead', - recordId: '{record.id}', - data: { - status: 'converted', - converted_opportunity: '{new_opportunity.id}', - }, - }, - position: { x: 600, y: 1080 }, - }, - - // ── 11. Wait — pause 24 h on a timer for prospect-response signal ────── - { - id: 'wait_prospect_response', - type: 'wait', - label: 'Wait 24 h for Prospect Response', - waitEventConfig: { - eventType: 'timer', - timerDuration: 'PT24H', - timeoutMs: 86_400_000, - onTimeout: 'continue', - }, - position: { x: 600, y: 1200 }, - }, - - // ── 12. Decision — did the rep advance the opportunity? ──────────────── - { - id: 'decide_followup', - type: 'decision', - label: 'Opportunity Advanced Beyond Prospecting?', - config: { - conditions: [ - { label: 'Follow-up complete', expression: 'new_opportunity.stage != "prospecting"' }, - { label: 'No follow-up', expression: 'true' }, - ], - }, - position: { x: 600, y: 1320 }, - }, - - // ── 13. Subflow — escalate to manager when follow-up is missing ──────── - { - id: 'escalate_to_manager', - type: 'subflow', - label: 'Escalate to Manager (Subflow)', - config: { - flowName: 'notify_manager_subflow', - inputs: { - opportunity_id: '{new_opportunity.id}', - reason: 'No rep follow-up within 24 h of lead conversion (score: {lead_score})', - }, - }, - position: { x: 800, y: 1440 }, - }, - - // ── 14. Error handler — catch enrichment / unexpected failures ───────── - { - id: 'error_handler', - type: 'script', - label: 'Log Error & Continue Scoring', - config: { - script: ` - // Enrichment failed — proceed with partial score (no enrichment bonus) - console.warn('Enrichment failed for lead', record.id, '; scoring without enrichment data.'); - variables.enrichment_data = {}; - `, - outputVariables: ['enrichment_data'], - }, - position: { x: 700, y: 360 }, - }, - - // ── End nodes ───────────────────────────────────────────────────────── - { id: 'end_disqualified', type: 'end', label: 'Lead Disqualified', position: { x: 0, y: 840 } }, - { id: 'end_converted', type: 'end', label: 'Lead Converted Successfully', position: { x: 400, y: 1440 } }, - { id: 'end_escalated', type: 'end', label: 'Lead Escalated to Manager', position: { x: 800, y: 1560 } }, - ], - - // ── Edge graph ─────────────────────────────────────────────────────────── - edges: [ - // Linear setup path - { id: 'e01', source: 'start', target: 'init_vars' }, - { id: 'e02', source: 'init_vars', target: 'get_account' }, - { id: 'e03', source: 'get_account', target: 'enrich_lead' }, - - // Enrichment: success path → score; fault path → error_handler - { id: 'e04', source: 'enrich_lead', target: 'score_lead', type: 'default' }, - { id: 'e05', source: 'enrich_lead', target: 'error_handler', type: 'fault', label: 'Enrichment Failed' }, - // Error handler rejoins the main path at score step - { id: 'e06', source: 'error_handler', target: 'score_lead' }, - - { id: 'e07', source: 'score_lead', target: 'decide_qualification' }, - - // Decision: qualified branch → parallel split; default → disqualify - { id: 'e08', source: 'decide_qualification', target: 'parallel_split', type: 'conditional', condition: 'lead_score >= 60', label: 'Qualified' }, - { id: 'e09', source: 'decide_qualification', target: 'update_lead_disqualified', type: 'conditional', isDefault: true, label: 'Not Qualified' }, - { id: 'e10', source: 'update_lead_disqualified', target: 'end_disqualified' }, - - // Parallel split → two branches - { id: 'e11', source: 'parallel_split', target: 'notify_sales_rep' }, - { id: 'e12', source: 'parallel_split', target: 'create_opportunity' }, - - // Both branches → join - { id: 'e13', source: 'notify_sales_rep', target: 'parallel_join' }, - { id: 'e14', source: 'create_opportunity', target: 'parallel_join' }, - - // Post-join → update lead → wait → follow-up decision - { id: 'e15', source: 'parallel_join', target: 'update_lead_converted' }, - { id: 'e16', source: 'update_lead_converted', target: 'wait_prospect_response' }, - { id: 'e17', source: 'wait_prospect_response', target: 'decide_followup' }, - - // Follow-up decision - { id: 'e18', source: 'decide_followup', target: 'end_converted', type: 'conditional', condition: 'new_opportunity.stage != "prospecting"', label: 'Follow-up Done' }, - { id: 'e19', source: 'decide_followup', target: 'escalate_to_manager', type: 'conditional', isDefault: true, label: 'No Follow-up' }, - { id: 'e20', source: 'escalate_to_manager', target: 'end_escalated' }, - ], -}; diff --git a/examples/app-crm/src/flows/opportunity-won.flow.ts b/examples/app-crm/src/flows/opportunity-won.flow.ts deleted file mode 100644 index ed90390ec7..0000000000 --- a/examples/app-crm/src/flows/opportunity-won.flow.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Flow } from '@objectstack/spec/automation'; - -/** - * Auto-launched flow that fires when an opportunity transitions to - * Closed Won. Demonstrates a record-triggered flow shape. - */ -export const OpportunityWonFlow: Flow = { - name: 'opportunity_won_notification', - label: 'Notify on Opportunity Won', - description: 'Sends a celebration email when an opportunity is closed-won.', - type: 'autolaunched', - - nodes: [ - { - id: 'start', - type: 'start', - label: 'On Opportunity Update', - config: { - objectName: 'crm_opportunity', - triggerType: 'record-after-update', - condition: 'stage == "closed_won" && previous.stage != "closed_won"', - }, - }, - { - id: 'send_email', - type: 'script', - label: 'Send Win Notification', - config: { - actionType: 'email', - inputs: { - to: '{record.owner.email}', - subject: '🎉 Opportunity Won: {record.name}', - template: 'opportunity_won_email', - }, - }, - }, - { id: 'end', type: 'end', label: 'End' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'send_email' }, - { id: 'e2', source: 'send_email', target: 'end' }, - ], -}; diff --git a/examples/app-crm/src/flows/renewal-reminder.flow.ts b/examples/app-crm/src/flows/renewal-reminder.flow.ts deleted file mode 100644 index 60fc56522b..0000000000 --- a/examples/app-crm/src/flows/renewal-reminder.flow.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Flow } from '@objectstack/spec/automation'; - -/** - * Renewal Reminder Flow - * - * A schedule-triggered flow that runs daily. It queries opportunities closed - * approximately 12 months ago and creates a renewal opportunity for each one - * that does not already have a linked renewal. - * - * Demonstrates: - * • schedule-type flow with cron config - * • get_record for a multi-record query - * • script node used as an inline loop (forEach over results) - * • create_record inside conditional logic within a script - * • assignment for accumulating a counter variable - * • clean variables + errorHandling wiring - */ -export const RenewalReminderFlow: Flow = { - name: 'renewal_reminder_flow', - label: 'Daily Renewal Opportunity Creator', - description: - 'Runs daily, finds closed-won opportunities with a close date ~12 months ago that have no renewal, and creates a renewal opportunity for each.', - type: 'schedule', - status: 'active', - version: 1, - runAs: 'system', - - variables: [ - { name: 'expiring_deals', type: 'array', isInput: false, isOutput: false }, - { name: 'renewals_created', type: 'number', isInput: false, isOutput: true }, - ], - - errorHandling: { - strategy: 'continue', - fallbackNodeId: 'log_errors', - }, - - nodes: [ - // ── Start — daily at 07:00 UTC ───────────────────────────────────────── - { - id: 'start', - type: 'start', - label: 'Daily 07:00 UTC', - config: { - triggerType: 'schedule', - cron: '0 7 * * *', - }, - position: { x: 400, y: 0 }, - }, - - // ── Init counter ────────────────────────────────────────────────────── - { - id: 'init_counter', - type: 'assignment', - label: 'Reset Renewal Counter', - config: { - assignments: [{ variable: 'renewals_created', value: 0 }], - }, - position: { x: 400, y: 120 }, - }, - - // ── Fetch expiring deals (close_date between 11 and 13 months ago) ───── - { - id: 'fetch_expiring', - type: 'get_record', - label: 'Fetch Deals Expiring ~12 Months Ago', - config: { - objectName: 'crm_opportunity', - filter: { - stage: 'closed_won', - close_date: { $gte: '{daysAgo(395)}', $lte: '{daysAgo(335)}' }, - renewal_of: null, - }, - outputVariable: 'expiring_deals', - limit: 200, - }, - outputSchema: { - expiring_deals: { type: 'array', description: 'Deals eligible for renewal' }, - }, - position: { x: 400, y: 240 }, - }, - - // ── Decision — any results? ──────────────────────────────────────────── - { - id: 'decide_has_results', - type: 'decision', - label: 'Any Expiring Deals?', - config: { - conditions: [ - { label: 'Has deals', expression: 'expiring_deals.length > 0' }, - { label: 'Nothing', expression: 'true' }, - ], - }, - position: { x: 400, y: 360 }, - }, - - // ── Script — iterate over deals and create renewals ──────────────────── - // (Script-as-loop avoids the partial loop executor gap noted in ADR.) - { - id: 'create_renewals', - type: 'script', - label: 'Create Renewal Opportunities', - config: { - script: ` - let count = 0; - for (const deal of expiring_deals) { - // Safety: skip if a renewal already exists (defensive; filter above should catch this) - const existingRenewal = await objectql.findOne('crm_opportunity', { - filter: { renewal_of: deal.id }, - }); - if (existingRenewal) continue; - - await objectql.insert('crm_opportunity', { - name: deal.name + ' — Renewal', - account: deal.account, - stage: 'prospecting', - amount: deal.amount, - probability: 20, - close_date: daysFromNow(90), - renewal_of: deal.id, - }); - count++; - } - variables.renewals_created = count; - `, - outputVariables: ['renewals_created'], - }, - position: { x: 400, y: 480 }, - }, - - // ── Log summary ─────────────────────────────────────────────────────── - { - id: 'log_summary', - type: 'script', - label: 'Log Renewal Run Summary', - config: { - script: `console.info('Renewal run complete — created', renewals_created, 'renewal opportunities.');`, - }, - position: { x: 400, y: 600 }, - }, - - // ── Error logger fallback ───────────────────────────────────────────── - { - id: 'log_errors', - type: 'script', - label: 'Log Flow Errors', - config: { - script: `console.error('renewal_reminder_flow encountered an error:', error);`, - }, - position: { x: 700, y: 360 }, - }, - - // ── End nodes ───────────────────────────────────────────────────────── - { id: 'end_nothing', type: 'end', label: 'No Expiring Deals Today', position: { x: 700, y: 480 } }, - { id: 'end_done', type: 'end', label: 'Renewals Created', position: { x: 400, y: 720 } }, - { id: 'end_error', type: 'end', label: 'Error Handled', position: { x: 700, y: 480 } }, - ], - - edges: [ - { id: 'e01', source: 'start', target: 'init_counter' }, - { id: 'e02', source: 'init_counter', target: 'fetch_expiring' }, - { id: 'e03', source: 'fetch_expiring', target: 'decide_has_results' }, - { id: 'e04', source: 'decide_has_results', target: 'create_renewals', type: 'conditional', condition: 'expiring_deals.length > 0', label: 'Has Deals' }, - { id: 'e05', source: 'decide_has_results', target: 'end_nothing', type: 'conditional', isDefault: true, label: 'Nothing to do' }, - { id: 'e06', source: 'create_renewals', target: 'log_summary' }, - { id: 'e07', source: 'log_summary', target: 'end_done' }, - // Fault paths - { id: 'e08', source: 'fetch_expiring', target: 'log_errors', type: 'fault', label: 'Query Failed' }, - { id: 'e09', source: 'create_renewals', target: 'log_errors', type: 'fault', label: 'Create Failed' }, - { id: 'e10', source: 'log_errors', target: 'end_error' }, - ], -}; diff --git a/examples/app-crm/src/flows/stale-opportunity.flow.ts b/examples/app-crm/src/flows/stale-opportunity.flow.ts deleted file mode 100644 index 037c70d48a..0000000000 --- a/examples/app-crm/src/flows/stale-opportunity.flow.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Flow } from '@objectstack/spec/automation'; - -/** - * Stale Opportunity sweep — scheduled flow. - * - * Migrated from the legacy time-based `workflow` metadata type (retired in - * ADR-0020). The original rule ran on a schedule, found open - * `crm_opportunity` records untouched for 30+ days, and (a) emailed the - * owner and (b) created a follow-up task. A scheduled sweep with - * side-effecting actions is precisely a schedule-triggered Flow, so it now - * lives here rather than as a bespoke workflow metadata type. - */ -export const StaleOpportunityFlow: Flow = { - name: 'stale_opportunity_sweep', - label: 'Stale Opportunity Sweep', - description: 'Daily sweep that notifies owners of open opportunities untouched for 30+ days and opens a follow-up task.', - type: 'schedule', - // A scheduled run has no trigger user, so it must declare its elevation: this - // sweep spans every owner's opportunities and opens follow-up tasks across - // them. Without this it would run UNSCOPED by default. (ADR-0049 / #1888) - runAs: 'system', - - nodes: [ - { - id: 'start', - type: 'start', - label: 'Daily Schedule', - config: { - triggerType: 'schedule', - // Every day at 08:00 — re-evaluate open opportunities for staleness. - cron: '0 8 * * *', - objectName: 'crm_opportunity', - filter: "stage != 'closed_won' && stage != 'closed_lost' && last_modified < daysAgo(30)", - }, - }, - { - id: 'notify_owner', - type: 'script', - label: 'Notify Owner', - config: { - actionType: 'email', - inputs: { - to: '{record.owner_email}', - subject: '⏰ Stale Opportunity: {record.name}', - template: 'stale_opportunity_alert', - }, - }, - }, - { - id: 'open_followup_task', - type: 'create_record', - label: 'Open Follow-up Task', - config: { - objectName: 'crm_activity', - inputs: { - subject: 'Follow up on stale opportunity: {record.name}', - description: 'This opportunity has not been updated in 30+ days. Please review and update.', - due_date: '{daysFromNow(3)}', - related_to: '{record.id}', - }, - }, - }, - { id: 'end', type: 'end', label: 'End' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'notify_owner' }, - { id: 'e2', source: 'notify_owner', target: 'open_followup_task' }, - { id: 'e3', source: 'open_followup_task', target: 'end' }, - ], -}; diff --git a/examples/app-crm/src/jobs/crm-jobs.ts b/examples/app-crm/src/jobs/crm-jobs.ts deleted file mode 100644 index 05fc33f8a8..0000000000 --- a/examples/app-crm/src/jobs/crm-jobs.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { JobInput } from '@objectstack/spec/system'; - -/** - * Nightly lead-scoring job — recomputes `lead_score` for all open leads. - * Handler key 'scoreLeads' must be registered in defineStack({ functions }). - */ -export const LeadScoringJob: JobInput = { - name: 'crm_lead_scoring', - label: 'Nightly Lead Score Refresh', - description: 'Recalculates lead_score for all open leads using engagement signals.', - schedule: { - type: 'cron', - expression: '0 2 * * *', - timezone: 'UTC', - }, - handler: 'scoreLeads', - retryPolicy: { - maxRetries: 2, - backoffMs: 5000, - backoffMultiplier: 2, - }, - timeout: 300000, - enabled: true, -}; - -/** - * Weekly pipeline report — aggregates deal data and emails managers. - */ -export const PipelineReportJob: JobInput = { - name: 'crm_pipeline_report', - label: 'Weekly Pipeline Report', - description: 'Generates and emails weekly pipeline summary to sales managers.', - schedule: { - type: 'cron', - expression: '0 8 * * 1', - timezone: 'UTC', - }, - handler: 'generatePipelineReport', - retryPolicy: { - maxRetries: 1, - backoffMs: 10000, - backoffMultiplier: 1, - }, - timeout: 120000, - enabled: true, -}; - -/** - * Daily renewal reminder sweep — kicks off renewal_reminder_flow for - * opportunities nearing contract expiry. - */ -export const RenewalSweepJob: JobInput = { - name: 'crm_renewal_sweep', - label: 'Daily Renewal Reminder Sweep', - description: 'Scans contracts expiring within 30 days and enqueues reminder flows.', - schedule: { - type: 'cron', - expression: '0 9 * * *', - timezone: 'UTC', - }, - handler: 'sweepRenewals', - timeout: 60000, - enabled: true, -}; diff --git a/examples/app-crm/src/portals/customer.portal.ts b/examples/app-crm/src/portals/customer.portal.ts deleted file mode 100644 index 356f9e536e..0000000000 --- a/examples/app-crm/src/portals/customer.portal.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Portal } from '@objectstack/spec/ui'; - -/** - * Customer Self-Service Portal — external users can view their account, - * submit activities, and track open opportunities without a full Studio login. - * - * Demonstrates: kind discriminator, profiles, navigation items, anonymous - * entry with rate-limiting, SEO metadata, and magic-link auth mode. - */ -export const CustomerPortal: Portal = { - kind: 'portal', - id: 'customer_self_service', - label: 'Customer Self-Service Portal', - description: 'External portal for customers to track their account and support activities.', - routePrefix: '/portal/customer', - layout: 'minimal', - authMode: 'magic-link', - locale: 'auto', - profiles: ['customer_portal_user'], - seo: { - title: 'Customer Portal — CRM Example', - description: 'Track your account, opportunities, and support activities.', - robots: 'noindex', - }, - navigation: [ - { - type: 'view', - id: 'my_activities', - label: 'My Activities', - icon: 'calendar', - order: 1, - viewRef: 'crm_activity.activity_grid', - }, - { - type: 'view', - id: 'my_account', - label: 'My Account', - icon: 'building', - order: 2, - viewRef: 'crm_account.account_list', - }, - { - type: 'url', - id: 'knowledge_base', - label: 'Knowledge Base', - icon: 'book-open', - order: 3, - url: 'https://docs.example.com', - target: '_blank', - }, - ], - // NOTE: `anonymousEntry` is a spec property with no runtime consumer yet — the - // routes here would never mount (verified: 404). For a WORKING public capture - // form, see the `web_to_lead` form view in `views/lead.view.ts` - // (`sharing.allowAnonymous` → live `/api/v1/forms/contact-us` endpoint). Re-add - // `anonymousEntry` here only once the runtime mounts it. - defaultRoute: { - viewRef: 'crm_activity.activity_grid', - }, - embeddable: false, - active: true, -}; diff --git a/examples/app-crm/src/reports/index.ts b/examples/app-crm/src/reports/index.ts deleted file mode 100644 index 257688117e..0000000000 --- a/examples/app-crm/src/reports/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -export { SalesByStageReport } from './sales-by-stage.report.js'; -export { SalesByAccountReport } from './sales-by-account.report.js'; diff --git a/examples/app-crm/src/reports/sales-by-account.report.ts b/examples/app-crm/src/reports/sales-by-account.report.ts deleted file mode 100644 index 6584f097f2..0000000000 --- a/examples/app-crm/src/reports/sales-by-account.report.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineReport } from '@objectstack/spec/ui'; - -/** - * Example report — total opportunity amount grouped by ACCOUNT. - * - * Bound to `opportunity_metrics` with rows = `account`, a LOOKUP dimension. - * The analytics layer resolves each account FK to its display name in `rows`, - * but exposes the raw FK via `drillRawRows` + `dimensionFields` (ADR-0021 D2), - * so drilling a row filters the opportunity list by the account's stored id — - * not its (possibly non-unique) display name. Paired with the currency-aware - * `total_amount` measure (USD), this exercises both render paths — Intl - * currency formatting AND raw-value lookup drill — end to end. - */ -export const SalesByAccountReport = defineReport({ - name: 'crm_sales_by_account', - label: 'Sales by Account', - description: 'Total opportunity amount grouped by account (lookup-dimension drill).', - type: 'summary', - dataset: 'opportunity_metrics', - rows: ['account'], - values: ['total_amount'], -}); diff --git a/examples/app-crm/src/reports/sales-by-stage.report.ts b/examples/app-crm/src/reports/sales-by-stage.report.ts deleted file mode 100644 index 14e75c7a73..0000000000 --- a/examples/app-crm/src/reports/sales-by-stage.report.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineReport } from '@objectstack/spec/ui'; - -/** - * Example report — total opportunity amount grouped by stage. - * - * ADR-0021 Phase 2: bound to the `opportunity_metrics` dataset (rows = stage, - * values = total_amount) alongside the legacy inline query. The legacy form was - * also corrected to actually group + sum (it previously listed rows flat despite - * its "grouped by stage" label), so both forms compute the same number and the - * reconciliation harness can verify them. - */ -export const SalesByStageReport = defineReport({ - name: 'crm_sales_by_stage', - label: 'Sales by Stage', - description: 'Total opportunity amount grouped by sales stage.', - type: 'summary', - dataset: 'opportunity_metrics', - rows: ['stage'], - values: ['total_amount'], -}); diff --git a/examples/app-crm/src/themes/crm.theme.ts b/examples/app-crm/src/themes/crm.theme.ts deleted file mode 100644 index c1ecfa8f0f..0000000000 --- a/examples/app-crm/src/themes/crm.theme.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineTheme } from '@objectstack/spec/ui'; - -/** - * Default CRM brand theme — light mode with professional blue palette. - */ -export const CrmLightTheme = defineTheme({ - name: 'crm_light', - label: 'CRM Light', - description: 'Default CRM theme — professional blue, light mode.', - mode: 'light', - colors: { - primary: '#1E6FD9', - secondary: '#6C757D', - accent: '#17A2B8', - background: '#FFFFFF', - surface: '#F8F9FA', - text: '#212529', - textSecondary: '#6C757D', - border: '#DEE2E6', - success: '#28A745', - warning: '#FFC107', - error: '#DC3545', - info: '#17A2B8', - }, - typography: { - fontFamily: { base: "'Inter', 'Segoe UI', system-ui, sans-serif" }, - fontSize: { - xs: '0.75rem', - sm: '0.875rem', - base: '1rem', - lg: '1.125rem', - xl: '1.25rem', - }, - fontWeight: { - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - lineHeight: { - tight: '1.25', - normal: '1.5', - relaxed: '1.75', - }, - }, - borderRadius: { - sm: '4px', - md: '6px', - lg: '8px', - xl: '12px', - full: '9999px', - }, - density: 'regular', - wcagContrast: 'AA', -}); - -/** - * Dark variant — same palette, dark surfaces. - */ -export const CrmDarkTheme = defineTheme({ - name: 'crm_dark', - label: 'CRM Dark', - description: 'CRM dark mode theme.', - mode: 'dark', - extends: 'crm_light', - colors: { - primary: '#4D9EF5', - secondary: '#ADB5BD', - accent: '#3DD5F3', - background: '#121212', - surface: '#1E1E2E', - text: '#E9ECEF', - textSecondary: '#ADB5BD', - border: '#343A40', - success: '#40C057', - warning: '#FFD43B', - error: '#FA5252', - info: '#3DD5F3', - }, - density: 'regular', -}); diff --git a/examples/app-crm/src/webhooks/crm-webhooks.ts b/examples/app-crm/src/webhooks/crm-webhooks.ts deleted file mode 100644 index 33715d87de..0000000000 --- a/examples/app-crm/src/webhooks/crm-webhooks.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineWebhook } from '@objectstack/spec/automation'; - -/** - * Notify external CRM bus whenever an opportunity is created or updated. - */ -export const OpportunityChangedWebhook = defineWebhook({ - name: 'crm_opportunity_changed', - label: 'Opportunity Created / Updated', - object: 'crm_opportunity', - triggers: ['create', 'update'], - url: 'https://hooks.example.com/crm/opportunity', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Source': 'objectstack-crm', - }, - payloadFields: ['id', 'name', 'stage', 'amount', 'owner_id', 'updated_at'], - authentication: { - type: 'bearer', - credentials: { token: 'env:CRM_WEBHOOK_TOKEN' }, - }, - retryPolicy: { - maxRetries: 3, - backoffStrategy: 'exponential', - initialDelayMs: 1000, - maxDelayMs: 30000, - }, - timeoutMs: 10000, - secret: 'env:CRM_WEBHOOK_SECRET', - isActive: true, - description: 'Fires on every opportunity create/update for downstream sync.', - tags: ['crm', 'sync'], -}); - -/** - * Notify Slack channel when a deal is won. - */ -export const DealWonSlackWebhook = defineWebhook({ - name: 'crm_deal_won_slack', - label: 'Deal Won → Slack', - object: 'crm_opportunity', - triggers: ['update'], - url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - payloadFields: ['id', 'name', 'amount', 'owner_id', 'close_date'], - isActive: true, - description: 'Posts to #wins Slack channel when stage=closed_won.', - tags: ['slack', 'notifications'], -}); diff --git a/examples/app-crm/test/smoke.test.ts b/examples/app-crm/test/smoke.test.ts index 43c48c923a..2d4b52715a 100644 --- a/examples/app-crm/test/smoke.test.ts +++ b/examples/app-crm/test/smoke.test.ts @@ -23,11 +23,13 @@ describe('app-crm minimal metadata bundle', () => { ]); }); - it('registers exactly one app, one dashboard, one hook, and at least 4 flows', () => { + it('registers exactly one app, one dashboard, one hook, and one flow', () => { expect(stack.apps).toHaveLength(1); expect(stack.dashboards).toHaveLength(1); expect(stack.hooks).toHaveLength(1); - expect((stack.flows ?? []).length).toBeGreaterThanOrEqual(4); + // One flow only — the convert-lead screen wizard. Automation breadth + // is the showcase's job. + expect(stack.flows).toHaveLength(1); }); it('includes a screen flow with input/output variables, screen nodes, and guard decision', () => { @@ -66,7 +68,9 @@ describe('app-crm minimal metadata bundle', () => { expect((stack.data ?? []).length).toBeGreaterThanOrEqual(3); }); - // Phase 2: full metadata coverage + // Infrastructure & security of the slim core (feature breadth lives in + // the showcase — its coverage manifest enforces it; see #2611/#2612 for + // the inert mappings/connectors this example used to demo): it('has datasources', () => { expect((stack.datasources ?? []).length).toBeGreaterThanOrEqual(1); }); @@ -84,24 +88,6 @@ describe('app-crm minimal metadata bundle', () => { expect(stack.i18n!.supportedLocales).toContain('zh-CN'); }); - it('has object extensions', () => { - expect((stack.objectExtensions ?? []).length).toBeGreaterThanOrEqual(1); - expect(stack.objectExtensions![0].extend).toBe('crm_contact'); - }); - - it('has a portal', () => { - expect((stack.portals ?? []).length).toBeGreaterThanOrEqual(1); - expect(stack.portals![0].routePrefix).toBe('/portal/customer'); - }); - - it('has themes (light + dark)', () => { - expect((stack.themes ?? []).length).toBeGreaterThanOrEqual(2); - }); - - it('has jobs', () => { - expect((stack.jobs ?? []).length).toBeGreaterThanOrEqual(2); - }); - it('has sharing rules (criteria + owner types)', () => { const rules = stack.sharingRules ?? []; expect(rules.length).toBeGreaterThanOrEqual(2); @@ -109,26 +95,6 @@ describe('app-crm minimal metadata bundle', () => { expect(rules.some((r) => r.type === 'owner')).toBe(true); }); - - it('has API endpoints', () => { - expect((stack.apis ?? []).length).toBeGreaterThanOrEqual(2); - }); - - it('has webhooks', () => { - expect((stack.webhooks ?? []).length).toBeGreaterThanOrEqual(1); - }); - - it('has import/export mappings', () => { - expect((stack.mappings ?? []).length).toBeGreaterThanOrEqual(1); - }); - - it('has analytics cubes', () => { - expect((stack.analyticsCubes ?? []).length).toBeGreaterThanOrEqual(1); - }); - - it('has connectors', () => { - expect((stack.connectors ?? []).length).toBeGreaterThanOrEqual(1); - }); }); describe('Pipeline dashboard', () => { diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 0cd7476b7e..34c8033750 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -29,6 +29,7 @@ import { allHooks } from './src/data/hooks/index.js'; import { allJobs } from './src/automation/jobs/index.js'; import { allEmails } from './src/system/emails/index.js'; import { allBooks } from './src/system/books/index.js'; +import { allApis } from './src/system/apis/index.js'; import { allRoles, allPermissionSets, @@ -180,6 +181,9 @@ export default defineStack({ flows: allFlows, jobs: allJobs, emailTemplates: allEmails, + // Declarative REST endpoints (object_operation + flow) — the metadata + // counterpart of the code-mounted recalc endpoint (see src/system/apis/). + apis: allApis, hooks: allHooks, webhooks: allWebhooks, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index e55b711950..428ab895a3 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -181,6 +181,12 @@ export const STACK_COLLECTION_COVERAGE: Record = { files: ['src/data/extensions/account.extension.ts'], notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).', }, + apis: { + status: 'demonstrated', + files: ['src/system/apis/index.ts'], + notes: + 'Declarative ApiEndpoint metadata (object_operation + flow targets), executed by the runtime dispatcher (handleApiEndpoint). Complements the code-mounted endpoint in src/system/server/ (router kind stays waived: code-only).', + }, mappings: { status: 'waived', reason: diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts new file mode 100644 index 0000000000..5c7ffd5239 --- /dev/null +++ b/examples/app-showcase/src/system/apis/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ApiEndpoint } from '@objectstack/spec/api'; + +/** + * Declarative API endpoints (`apis:`) — the metadata-authored counterpart of + * the code-mounted endpoint in src/system/server/recalc-endpoint.ts. The + * runtime dispatcher matches these by path+method and executes the target + * (`object_operation` → a data read; `flow` → a flow run) with no handler + * code. Migrated here from app-crm when that example was slimmed back to a + * pure loading-pipeline smoke fixture. + */ + +/** Read-only data projection: GET a filtered task list through a stable URL. */ +export const TaskFeedEndpoint: ApiEndpoint = { + name: 'showcase_task_feed', + path: '/api/v1/showcase/tasks', + method: 'GET', + summary: 'Task feed', + description: 'Returns tasks via a declarative object_operation endpoint — no handler code.', + type: 'object_operation', + target: 'showcase_task', + objectParams: { + object: 'showcase_task', + operation: 'find', + }, + authRequired: true, + cacheTtl: 30, +}; + +/** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */ +export const InquiryPurgeEndpoint: ApiEndpoint = { + name: 'showcase_inquiry_purge_api', + path: '/api/v1/showcase/inquiries/purge', + method: 'POST', + summary: 'Purge closed inquiries', + description: 'Invokes the showcase_inquiry_purge flow (get_record + delete_record janitor) over HTTP.', + type: 'flow', + target: 'showcase_inquiry_purge', + authRequired: true, +}; + +export const allApis = [TaskFeedEndpoint, InquiryPurgeEndpoint]; diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index 9b165d31ca..967609dd56 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -33,6 +33,31 @@ describe('showcase gap fill — analytics cube', () => { }); }); +describe('showcase gap fill — declarative api endpoints', () => { + it('is wired into the stack definition', () => { + const apis = (stack as { apis?: Array<{ name: string }> }).apis ?? []; + expect(apis.map((a) => a.name)).toEqual( + expect.arrayContaining(['showcase_task_feed', 'showcase_inquiry_purge_api']), + ); + }); + + it('flow-typed endpoints target flows that actually exist (no 500 at dispatch)', () => { + const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? []; + const flowNames = ((stack as { flows?: Array<{ name: string }> }).flows ?? []).map((f) => f.name); + for (const api of apis.filter((a) => a.type === 'flow')) { + expect(flowNames, `api endpoint targets missing flow '${api.target}'`).toContain(api.target); + } + }); + + it('object_operation endpoints target objects that exist', () => { + const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? []; + const objectNames = ((stack as { objects?: Array<{ name: string }> }).objects ?? []).map((o) => o.name); + for (const api of apis.filter((a) => a.type === 'object_operation')) { + expect(objectNames, `api endpoint targets missing object '${api.target}'`).toContain(api.target); + } + }); +}); + describe('showcase gap fill — object extension (overlay merge)', () => { it('is wired into the stack definition', () => { const exts = (stack as { objectExtensions?: Array<{ extend: string }> }).objectExtensions ?? []; diff --git a/examples/app-todo/README.md b/examples/app-todo/README.md index e1052a5fac..f20a744af1 100644 --- a/examples/app-todo/README.md +++ b/examples/app-todo/README.md @@ -5,11 +5,15 @@ A comprehensive Todo application demonstrating the ObjectStack Protocol with tas ## 🎯 Purpose This example serves as a **quick-start reference** for learning ObjectStack basics. It demonstrates: -- Object definition with essential field types, validations, and workflows +- Object definition with essential field types and validations (record + state lives in validation rules per ADR-0020 — there is no `workflow` type) - Actions for task management (complete, defer, clone, etc.) -- Dashboard with key metrics and visualizations -- Reports for status, priority, owner, and time tracking analysis +- Dashboard with key metrics and visualizations, backed by the dataset + semantic layer (ADR-0021) +- Reports for status, priority, owner, and time-tracking analysis - Automation flows for reminders, escalation, and recurring tasks +- i18n bundles for **en / zh-CN / ja-JP** (the ja-JP bundle is unique + in-repo) - Full configuration using `objectstack.config.ts` with the standard **by-type** layout For a **comprehensive enterprise example** with advanced features (AI agents, security profiles, sharing rules), see the **[HotCRM reference app](https://github.com/objectstack-ai/hotcrm)** (separate repository). @@ -22,20 +26,21 @@ Follows the **by-type** directory layout — the ObjectStack standard aligned wi examples/app-todo/ ├── src/ │ ├── objects/ # 📦 Data Models -│ │ ├── task.object.ts # Task object definition (fields, validations, workflows) -│ │ └── task.hook.ts # Data hooks / triggers -│ ├── actions/ # ⚡ Buttons & Actions -│ │ └── task.actions.ts # Complete, Start, Defer, Clone, Mass Complete, Export -│ ├── apps/ # 🚀 App Configuration -│ │ └── todo.app.ts # Navigation, branding -│ ├── dashboards/ # 📊 BI Dashboards -│ │ └── task.dashboard.ts # Metrics, charts, task lists -│ ├── reports/ # 📈 Analytics Reports -│ │ └── task.report.ts # By status, priority, owner, overdue, time tracking -│ └── flows/ # 🔄 Automation Flows -│ └── task.flow.ts # Reminder, escalation, completion, quick-add +│ │ ├── task.object.ts # Task object (fields + validation rules) +│ │ └── task.hook.ts # Data hooks +│ ├── views/ # 👓 List-view lenses (incl. Overdue — ADR-0017) +│ ├── datasets/ # 🧮 Semantic layer feeding dashboard/reports (ADR-0021) +│ ├── actions/ # ⚡ Complete, Start, Defer, Clone, Mass Complete, Export +│ ├── apps/ # 🚀 Navigation, branding +│ ├── dashboards/ # 📊 Metrics, charts, task lists +│ ├── reports/ # 📈 By status / priority / owner / completed / time tracking +│ ├── flows/ # 🔄 Reminder, escalation, completion, quick-add +│ ├── translations/ # 🌍 en · zh-CN · ja-JP bundles (+ completeness spec) +│ ├── data/ # 🌱 Seed data +│ └── docs/ # 📚 Package docs (ADR-0046) ├── test/ -│ └── seed.test.ts # 🧪 Seed data verification +│ ├── seed-check.ts # 🧪 Seed/boot verification script (tsx) +│ └── mcp-actions.e2e.ts # 🤖 MCP business-action E2E (pnpm test:mcp) ├── objectstack.config.ts # Application manifest └── README.md ``` @@ -69,10 +74,12 @@ examples/app-todo/ - Charts (status pie, priority bar, weekly trend line, category donut) - Task tables (overdue, due today) -### Reports (6) +### Reports (5) - Tasks by Status / Priority / Owner -- Overdue Tasks / Completed Tasks +- Completed Tasks - Time Tracking (estimated vs actual hours matrix) +- (Overdue Tasks is deliberately **not** a report — a flat record list is a + ListView lens, ADR-0021; see `src/views/task.view.ts`) ### Automation Flows (4) - **Task Reminder** — Daily scheduled reminder for tasks due tomorrow @@ -80,11 +87,12 @@ examples/app-todo/ - **Task Completion** — Auto-create next occurrence for recurring tasks - **Quick Add Task** — Screen flow for fast task creation -### Validations & Workflows -- Completed date required when status is "completed" -- Recurrence type required for recurring tasks -- Auto-set `is_completed`, `completed_date`, `progress_percent` on status change -- Auto-detect overdue tasks and send urgent notifications +### Validations & Automation +- Completed date required when status is "completed" (validation rule) +- Recurrence type required for recurring tasks (validation rule) +- Auto-set `is_completed`, `completed_date`, `progress_percent` on status + change (data hook) +- Auto-detect overdue tasks and send urgent notifications (flow) ## 💡 How to Run @@ -108,43 +116,29 @@ pnpm --filter @objectstack/example-todo build ### Explore the Config Open `objectstack.config.ts` to see how all pieces connect via `defineStack()`. -## 🤖 AI Demo (NEW in v5) +## 🤖 MCP Demo — business actions over the open AI surface -This example also showcases the v1 AI capabilities. Run the end-to-end demo: +The open framework exposes AI via **`@objectstack/mcp`** (BYO-AI; the +in-product chat lives in the cloud distribution — ADR-0063). This example +ships a real end-to-end proof, **no API key required**: ```bash -pnpm --filter @objectstack/example-todo test:ai +pnpm --filter @objectstack/example-todo test:mcp ``` -What it does — **no API key required**: +What it does (`test/mcp-actions.e2e.ts`): -1. Boots the Todo stack with `@objectstack/service-ai` and the in-memory `MemoryLLMAdapter` -2. Registers a `memory` model in the runtime `ModelRegistry` for cost attribution -3. Calls the built-in `query_data` tool with a natural-language request (`"list my todo_task records"`) -4. The tool: - - Retrieves the matching object schema (`SchemaRetriever`) - - Generates an ObjectQL plan via `ai.generateObject()` (heuristic in memory mode) - - Executes the plan against the data engine - - Returns the records -5. Verifies the call was auto-recorded as a row in the `ai_traces` object with `operation='generate_object'`, latency, status, and model +1. Boots a self-host composition of ONLY the open framework — + `@objectstack/runtime` + ObjectQL + a driver + this seeded app + `@objectstack/mcp` +2. Drives the real `MCPServerRuntime` over JSON-RPC — the same code path an + external MCP client (e.g. Claude) hits +3. Lists the app's business actions as MCP tools, then executes one via + `run_action` → `engine.executeAction` → the registered handler → the + real driver, **permission-enforced** end to end -### Agent Demo (`pnpm test:agent`) - -A higher-level demo that exercises the **`data_chat` built-in agent** end-to-end: - -```bash -pnpm --filter @objectstack/example-todo test:agent -``` - -1. Sends a natural-language user message to `AIService.chatWithTools()` (the same path the REST endpoint `POST /api/v1/ai/agents/data_chat/chat` uses) -2. `MemoryLLMAdapter` returns a `query_data` tool call -3. The tool registry executes it, feeds the result back -4. The adapter summarises: `"[memory] Found 8 records for ..."` -5. Verifies a `chat_with_tools` row was persisted in `ai_traces` - -This is the canonical "ask in English, get real data" loop. Swap in a real LLM adapter and the loop carries `data_chat` directly to production — no code changes. - -To switch to a real LLM, replace `MemoryLLMAdapter` with the auto-detected `VercelLLMAdapter` and set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY` — everything else stays the same. +Point any MCP client at the running server and the same tools are live — +that is the community-edition "ask in natural language, act on real data" +path. ## 📖 Learning Path @@ -162,4 +156,4 @@ To switch to a real LLM, replace `MemoryLLMAdapter` with the auto-detected `Verc ## 📝 License -MIT +Apache-2.0 diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 6c64071ba4..7844c9491b 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -17,7 +17,7 @@ "validate": "objectstack validate", "typecheck": "tsc --noEmit", "test": "objectstack test", - "test:mcp": "tsx test/mcp-actions.test.ts" + "test:mcp": "tsx test/mcp-actions.e2e.ts" }, "dependencies": { "@objectstack/client": "workspace:*", diff --git a/examples/app-todo/src/dashboards/task.dashboard.ts b/examples/app-todo/src/dashboards/task.dashboard.ts index 826159db4f..1decd2bf0f 100644 --- a/examples/app-todo/src/dashboards/task.dashboard.ts +++ b/examples/app-todo/src/dashboards/task.dashboard.ts @@ -5,14 +5,12 @@ import type { Dashboard } from '@objectstack/spec/ui'; /** * Task Overview dashboard. * - * ADR-0021 Phase 2: every widget is bound to the `task_metrics` dataset - * (`dataset` + `dimensions` + `values`, measures/dimensions referenced BY NAME) - * so the numbers stay consistent with every other surface. The legacy inline - * query (`object` + `categoryField` + `valueField` + `aggregate` + `filter`) is - * retained side-by-side during the dual-form window — the reconciliation - * harness asserts the two forms return identical numbers before the inline - * form is removed (see scripts/analytics-reconcile). The widget `filter` - * doubles as the dataset-bound `runtimeFilter` (presentation scope). + * ADR-0021 single-form: every widget is bound to the `task_metrics` dataset + * (`dataset` + `dimensions` + `values`, measures/dimensions referenced BY + * NAME) so the numbers stay consistent with every other surface. The + * dual-form migration window is over — no widget carries the legacy inline + * query form. The widget `filter` doubles as the dataset-bound + * `runtimeFilter` (presentation scope). */ export const TaskDashboard: Dashboard = { name: 'task_dashboard', diff --git a/examples/app-todo/src/reports/task.report.ts b/examples/app-todo/src/reports/task.report.ts index d91bf53c11..eb97541824 100644 --- a/examples/app-todo/src/reports/task.report.ts +++ b/examples/app-todo/src/reports/task.report.ts @@ -2,12 +2,11 @@ import { defineReport } from '@objectstack/spec/ui'; -// ADR-0021 Phase 2: each report below carries a `task_metrics` dataset binding -// (`dataset` + `rows` + `values`, measures referenced BY NAME) alongside the -// legacy inline query during the dual-form window. The reconciliation harness -// asserts both forms return identical numbers (scripts/analytics-reconcile). -// When the inline form is removed, the detail columns move to a click-through -// drilldown (per the migration decision); `overdue_tasks` becomes a ListView. +// ADR-0021 single-form: each report binds the `task_metrics` dataset +// (`dataset` + `rows` + `values`, measures referenced BY NAME). The dual-form +// migration window is over — the legacy inline query form is gone, and the +// former `overdue_tasks` report now lives as a ListView lens on the task +// object (src/views/task.view.ts). /** Tasks by Status Report */ export const TasksByStatusReport = defineReport({ diff --git a/examples/app-todo/src/translations/translation-completeness.test.ts b/examples/app-todo/src/translations/translation-completeness.test.ts index c81a7e72ee..b24959fcfe 100644 --- a/examples/app-todo/src/translations/translation-completeness.test.ts +++ b/examples/app-todo/src/translations/translation-completeness.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { Task } from '../objects/task.object'; import { en } from './en'; import { zhCN } from './zh-CN'; +import { jaJP } from './ja-JP'; import type { TranslationData } from '@objectstack/spec/system'; /** @@ -25,6 +26,9 @@ const selectFields = Object.entries(Task.fields) describe.each([ ['en', en], ['zh-CN', zhCN], + // ja-JP is the repo's only Japanese bundle — keep it held to the same + // completeness bar as the other declared locales. + ['ja-JP', jaJP], ] as [string, TranslationData][])('%s translation completeness', (locale, t) => { it('should have task object translation', () => { diff --git a/examples/app-todo/test/mcp-actions.test.ts b/examples/app-todo/test/mcp-actions.e2e.ts similarity index 100% rename from examples/app-todo/test/mcp-actions.test.ts rename to examples/app-todo/test/mcp-actions.e2e.ts diff --git a/examples/app-todo/test/seed.test.ts b/examples/app-todo/test/seed-check.ts similarity index 100% rename from examples/app-todo/test/seed.test.ts rename to examples/app-todo/test/seed-check.ts diff --git a/examples/embed-objectql/README.md b/examples/embed-objectql/README.md index fe9602b1a1..1e8adfbadf 100644 --- a/examples/embed-objectql/README.md +++ b/examples/embed-objectql/README.md @@ -5,7 +5,7 @@ plugins, no metadata-management protocol. This is the path for a thin host (e.g. a gateway, an edge worker, a CLI tool) that wants the query/CRUD engine and the *same* object definitions as a full ObjectStack backend, without the platform. -## The point (ADR-0076) +## The point (ADR-0076 core tiering — Proposed; the `/core` boundary itself has shipped) Import from the **lean entry**: