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
16 changes: 16 additions & 0 deletions .changeset/2366-record-actions-follow-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"hotcrm": minor
---

Clause-②: no
Metadata-protocol: none

**Record pages offer only the actions a record can still take, and quotes and cases get buttons for their status moves (#2366).** Who may do what is unchanged.

- **Quotes:** each status move made by hand has a button, shown only on the statuses it leaves from: **Submit for Review**, **Mark Presented**, **Accept** (asks first), **Reject** and **Revise** (back to Draft). An Accepted, Rejected or Expired quote shows no **Edit**, and its line items show no **New**. A Rejected quote is now frozen like an Accepted one until **Revise** takes it back to Draft: only Internal Notes can change, and its lines take no change. A new quote can no longer be created as Accepted, Rejected or Expired: the New form does not offer them, and the save is refused.
- **Cases:** a Resolved or Closed case has a **Reopen Case** button, offered to whoever may edit cases. It moves the case back to In Progress. The SLA Due Date is kept and a recorded breach stays recorded; the Service Manual says so.
- **Leads:** a converted lead shows no **Edit** and cannot be edited inline. The lock behind it is unchanged.
- **Campaigns:** Number Sent, Responses, Leads, Converted Leads, Opportunities, Won Opportunities and Actual Revenue are read-only, and so are a member's Response Date and Has Responded. The system keeps all of them, and a value typed through the API is now dropped instead of standing until the next recompute.
- **Status colours:** the status options of quotes, campaigns, campaign members and knowledge articles declare whether each one is open, won or lost, so a record's path draws each one from that declaration instead of guessing from the value's name.

The in-app Sales, Service and Marketing manuals describe each change in English and Chinese, and the documentation site's Quotes page says how a rejected quote is revised.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

**HotCRM is the reference implementation of that statement for a CRM.**

- **Its business ontology is the app.** 20 business objects and the relations between them, **32 actions**, 33 flows, 6 permission sets, 12 positions and 11 sharing rules, and 6 AI skills — typed [ObjectStack](https://github.com/objectstack-ai/objectstack) metadata under `src/`, checked by `pnpm validate`.
- **Its business ontology is the app.** 20 business objects and the relations between them, **38 actions**, 33 flows, 6 permission sets, 12 positions and 11 sharing rules, and 6 AI skills — typed [ObjectStack](https://github.com/objectstack-ai/objectstack) metadata under `src/`, checked by `pnpm validate`.
- **The database, the API, the console UI and the agent tools derive from it.** One `pnpm dev` boot creates the tables, serves the REST API at `/api/v1/data/crm_account` and its siblings, the Console at `/_console/`, and an MCP server at `/api/v1/mcp`. Nothing under `src/` defines a route handler or a React component.
- **Views, dashboards and translations are projections of it.** List views, record pages, 5 dashboards and the app's navigation, in 4 languages (en, zh-CN, es-ES, ja-JP): the surfaces people use the ontology through, not a second copy of it.

Expand Down Expand Up @@ -89,7 +89,7 @@ HotCRM defines no agent of its own; its 6 AI skills are metadata for a platform

## ✨ What you get

**20 business objects** across four business domains — sales, service, revenue and marketing; the repository layout below says what each package covers, and the roster itself is `src/*/objects/*.object.ts`. Around them: **32 actions**, 33 flows, **5 dashboards**, 10 reports, 9 datasets, 6 AI skills, 4 language bundles (en, zh-CN, es-ES, ja-JP), 6 permission sets, 12 positions and 11 sharing rules. `pnpm build` prints most of these counts on every build.
**20 business objects** across four business domains — sales, service, revenue and marketing; the repository layout below says what each package covers, and the roster itself is `src/*/objects/*.object.ts`. Around them: **38 actions**, 33 flows, **5 dashboards**, 10 reports, 9 datasets, 6 AI skills, 4 language bundles (en, zh-CN, es-ES, ja-JP), 6 permission sets, 12 positions and 11 sharing rules. `pnpm build` prints most of these counts on every build.

> **Business reader?** The ObjectStack docs tour every one of these capabilities in plain business language — [What Can It Do?](https://objectstack.ai/docs/capabilities) — with HotCRM as the running example on every page.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/quotes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ To write the covering email inside HotCRM, use **Send Email** on the customer's
There are no accept or reject buttons either. The customer's decision is recorded the same way: **Edit** the quote, set **Status**, click **Update**.

- **Accepted** — the save that makes a quote *Accepted* is what drafts the contract and close-wins the deal, as described in [What happens automatically](#what-happens-automatically). While an approval holds the deal, that save is refused instead (see [Built-in rules](#built-in-rules)). From then on the quote is frozen: a change to any field except **Internal Notes** is refused, Status included. The one change it still takes is a link being emptied: when the contact or opportunity it names is deleted, the quote stays and that field is cleared. Its line items are frozen with it: a line can no longer be added, changed or removed, and the same holds for an *Expired* quote. Deleting the whole quote is not affected, and its lines go with it.
- **Rejected** — nothing else happens. No contract is drafted and the opportunity keeps its stage, so you can quote again: set the rejected quote back to *Draft*, revise it and present it again, or click **Generate Quote** on the opportunity for a fresh one.
- **Rejected** — nothing else happens. No contract is drafted and the opportunity keeps its stage, so you can quote again: click **Revise** to set the rejected quote back to *Draft*, revise it and present it again, or click **Generate Quote** on the opportunity for a fresh one. Until it is back at *Draft*, a rejected quote is frozen like an accepted one: only **Internal Notes** can change, and its line items take no change.

The row of statuses across the top of the quote shows where it stands. It is a display, not a set of buttons.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/quotes.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ HotCRM 不会替你发送报价。报价记录上没有发送按钮,应用既
接受和拒绝也都没有按钮。客户的决定用同样的方式记录:**编辑**报价,修改**状态**,点击**更新**。

- **已接受**——把报价改为*已接受*的那次保存,就是起草合同并把商机推进为成交的那个动作,详见上文的[自动发生的事](#自动发生的事)。商机被审批卡住时,这次保存会被拒绝(见[内置规则](#内置规则))。此后这份报价被冻结:除**内部备注**以外,任何字段的修改都会被拒绝,状态也不例外。唯一还能落地的改动是清空一条链接:它指名的联系人或商机被删除时,报价保留,那一栏被清空。它的行项目也随之冻结:不能再新增、修改或删除任何一行,*已过期*的报价同样如此。删除整份报价不受影响,它的行项目会随之一起删除。
- **已拒绝**——不会引发任何其他变化。不会起草合同,商机保持原阶段,所以你可以重新报价:把这份被拒绝的报价改回*草稿*,修改之后再次提交;或者在商机上点击**生成报价单**,另生成一份新的。
- **已拒绝**——不会引发任何其他变化。不会起草合同,商机保持原阶段,所以你可以重新报价:点击**修订**把这份被拒绝的报价改回*草稿*,修改之后再次提交;或者在商机上点击**生成报价单**,另生成一份新的。在退回*草稿*之前,被拒绝的报价和已接受的报价一样被冻结:只有**内部备注**还能修改,报价明细也不能改动。

报价顶部那一排状态只显示这份报价当前所处的位置,它是一个显示,不是一组按钮。

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/quotes.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ HotCRM 不會替你發送報價。報價記錄上沒有發送按鈕,應用既
接受和拒絕也都沒有按鈕。客戶的決定用同樣的方式記錄:在報價上點擊 **Edit**,修改**狀態**,點擊 **Update**。

- **已接受**——把報價改為*已接受*的那次儲存,就是起草合約並把商機推進為成交的那個動作,詳見上文的[自動發生的事](#自動發生的事)。商機被審批卡住時,這次儲存會被拒絕(見[內建規則](#內建規則))。此後這份報價被凍結:除**內部備註**以外,任何欄位的修改都會被拒絕,狀態也不例外。唯一還能落地的改動是清空一條連結:它指名的聯絡人或商機被刪除時,報價保留,那一欄被清空。它的行項目也隨之凍結:不能再新增、修改或刪除任何一行,*已過期*的報價同樣如此。刪除整份報價不受影響,它的行項目會隨之一起刪除。
- **已拒絕**——不會引發任何其他變化。不會起草合約,商機保持原階段,所以你可以重新報價:把這份被拒絕的報價改回*草稿*,修改之後再次提交;或者在商機上點擊**生成報價單**,另生成一份新的。
- **已拒絕**——不會引發任何其他變化。不會起草合約,商機保持原階段,所以你可以重新報價:點擊**修訂**把這份被拒絕的報價改回*草稿*,修改之後再次提交;或者在商機上點擊**生成報價單**,另生成一份新的。在退回*草稿*之前,被拒絕的報價和已接受的報價一樣被凍結:只有**內部備註**還能修改,報價明細也不能改動。

報價頂部那一排狀態只顯示這份報價目前所處的位置,它是一個顯示,不是一組按鈕。

Expand Down
4 changes: 2 additions & 2 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ loader registers:
```text
HotCRM v6.0.0
Data: 20 Objects 375 Fields
UI: 1 Apps 14 Views 6 Pages 5 Dashboards 10 Reports 32 Actions
UI: 1 Apps 14 Views 6 Pages 5 Dashboards 10 Reports 38 Actions
Logic: 33 Flows
Security: 12 Positions 7 Permissions
```
Expand All @@ -31,7 +31,7 @@ Validation command:
pnpm validate
```

> **`32 Actions` is the REGISTRATION count, not a count of distinct action
> **`38 Actions` is the REGISTRATION count, not a count of distinct action
> definitions.** One action bound to five objects registers five times, so the
> source tree's `*.actions.ts` files and this figure answer different
> questions. The registration count is the calibre this repo states to readers
Expand Down
4 changes: 4 additions & 0 deletions src/docs/crm_sales_leads.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,8 @@ opportunity (and for its name and amount). On confirm, the system creates:
- optionally an **Opportunity** with the name/amount you entered.

You don't re-key anything — company and contact details carry over from the lead.

A converted lead is locked: its page shows no **Edit**, and its fields cannot be
changed inline. Its **Conversion** section links to the account, contact and
opportunity it became; keep working there.
What happens to the deal next is in [Opportunities](./crm_sales_opportunities.md).
2 changes: 2 additions & 0 deletions src/docs/crm_sales_leads.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@ stateDiagram-v2
- 可选的、使用你输入的名称和金额的**商机**。

无需重复录入——公司和联系人信息都从线索带过来。商机接下来会经历什么,见[商机](./crm_sales_opportunities.md)。

已转化的线索会被锁定:它的页面不显示**编辑**,字段也不能在页面上直接修改。它的**转化**分组链接到它生成的客户、联系人和商机,请在那里继续工作。
3 changes: 3 additions & 0 deletions src/marketing/docs/crm_marketing_campaigns.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ answering and a lead converting, and those are the statuses it has.
The numbers sit on the campaign itself, and they are kept current as members
are added, answer or leave, as their leads convert, and as deals name the
campaign. A running campaign shows real figures; you do not wait for it to end.
The system keeps them, so they are read-only on the campaign's form. On a
member, **Response Date** and **Has Responded** are read-only the same way: they
follow the member's status, which **Mark Responded** sets.

- **Number Sent** — every member of the campaign, whatever their status.
- **Number of Responses** — members who responded, whatever their status now:
Expand Down
2 changes: 1 addition & 1 deletion src/marketing/docs/crm_marketing_campaigns.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ HotCRM 不跟踪打开、点击或退信。它能观察到的是一个人作出

## 各个数字统计的是什么

这些数字就在营销活动记录上,并且随时保持最新:成员加入、回应或移除,其线索转化,以及商机关联到该活动时都会重算。正在进行的活动显示的就是真实数字,不必等它结束。
这些数字就在营销活动记录上,并且随时保持最新:成员加入、回应或移除,其线索转化,以及商机关联到该活动时都会重算。正在进行的活动显示的就是真实数字,不必等它结束。这些数字由系统维护,所以在营销活动的表单上是只读的。成员上的**响应时间**和**已响应**同样是只读的:它们跟随成员状态,而成员状态由**标记为已响应**设定。

- **发送数量**——活动的全部成员,不论状态。
- **响应数量**——作出过回应的成员,不论现在处于什么状态:**已响应**和**已转化**的成员都算,先回应、后来变为**已退订**的成员也仍算作一次响应。
Expand Down
11 changes: 11 additions & 0 deletions src/marketing/objects/campaign.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ const campaignValidation: Hook = {
* which meant a campaign reported zeros for its entire useful life and became
* accurate on the day everybody stopped looking at it. Completion is now simply
* one of the transitions this catches.
*
* `runAs: 'system'` (#2366 row 4, AGENTS.md rule 9), as its three siblings
* have been since #2014 and for their reason: a rollup must not depend on who
* fired it — run as the marketer moving the status, it counted only the deals
* that marketer may see. It is also what lets its write land since the metric
* block became `readonly` (`campaign.object.ts`): a readonly field is stripped
* from a non-system write. Organization (rule 10): every read is keyed to this
* campaign's id (and the leads its members name), and the elevated `ctx.api`
* keeps the trigger's tenant. The campaign's own status write stays the
* caller's.
*/
const campaignMetricsRefresh: Hook = {
name: 'campaign_metrics_refresh',
Expand All @@ -133,6 +143,7 @@ const campaignMetricsRefresh: Hook = {
priority: 800,
async: true,
onError: 'log',
runAs: 'system',
description: 'Recompute the campaign metric block whenever the campaign status moves.',
handler: async (ctx: HookContext) => {
const { input } = ctx;
Expand Down
52 changes: 30 additions & 22 deletions src/marketing/objects/campaign.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,14 @@ export const Campaign = ObjectSchema.create({
status: Field.select({
group: 'basic',
label: 'Status',
// `kind` (#2366 row 10, the same defect on the campaign's own path):
// Aborted is drawn as the unsuccessful end, not as a step after
// Completed. `test/status-option-kinds.test.ts`.
options: [
{ label: 'Planning', value: 'planning', color: '#999999', default: true },
{ label: 'In Progress', value: 'in_progress', color: '#FFA500' },
{ label: 'Completed', value: 'completed', color: '#00AA00' },
{ label: 'Aborted', value: 'aborted', color: '#FF0000' },
{ label: 'Planning', value: 'planning', color: '#999999', default: true, kind: 'open' },
{ label: 'In Progress', value: 'in_progress', color: '#FFA500', kind: 'open' },
{ label: 'Completed', value: 'completed', color: '#00AA00', kind: 'won' },
{ label: 'Aborted', value: 'aborted', color: '#FF0000', kind: 'lost' },
],
required: true,
storage: { notNull: true },
Expand Down Expand Up @@ -171,26 +174,25 @@ export const Campaign = ObjectSchema.create({
min: 0,
}),

// ⛔ NOT `readonly` (here and on the num_* snapshot fields below), and the
// reason holds on the pinned engine rather than on the retired 16.x claim
// this note used to carry. The metric block — `CAMPAIGN_METRIC_FIELDS` in
// `campaign.hook.ts` — is written by FOUR refresh hooks, not by the
// long-retired `campaign_snapshot_metrics`: `campaign_metrics_refresh`,
// `campaign_attribution_refresh`, `campaign_lead_conversion_refresh` and
// `campaign_member_metrics_refresh`. Every one of them writes
// `api.object('crm_campaign').update(...)`, and none declares `Hook.runAs`,
// which defaults to `'inherit'` — so `ctx.api` is a `ScopedContext` over the
// ACTING USER's context, and the keys are CALLER-supplied to a
// non-`isSystem` UPDATE, exactly what `stripReadonlyFields` deletes. Their
// least-privileged trigger is an ordinary user edit — a rep moving a
// campaign's status, saving an opportunity, enrolling a member — so
// `readonly` here would freeze the whole block at its seeded values.
// Measured in `test/activity-recency.test.ts`, which runs this same
// `ctx.api` shape through the real engine under a plain user context.
// `readonly` (#2366 row 4), here and on the six `num_*` counts below: the
// metric block — `CAMPAIGN_METRIC_FIELDS` in `campaign.hook.ts` — is the
// four refresh hooks' to write, never a person's. On the form they were
// editable, and a 999 typed into Number Sent stayed until the next member
// write recomputed it (measured on 18.2.0, #2320 Marketing S3).
//
// The hooks still land their writes because all four declare
// `runAs: 'system'`: a `readonly` field is stripped from a non-system
// write (`FieldSchema.readonly`), and an elevated hook's `ctx.api` is a
// system write. Three were elevated by #2014 so a rollup does not depend on
// who fired it; `campaign_metrics_refresh` joined them with this change,
// for the same reason and because it would otherwise be stripped. Its note
// says so. `test/campaign-metric-fields-readonly.test.ts` pins both halves:
// a user's write of a count is dropped, and every refresh still lands.
actual_revenue: Field.currency({
group: 'budget',
label: 'Actual Revenue',
min: 0,
readonly: true,
}),

// Metrics
Expand All @@ -201,43 +203,49 @@ export const Campaign = ObjectSchema.create({
min: 0,
}),

// NOT `readonly`: written by the same four refresh hooks, and for the same
// reason — see the note on `actual_revenue` above.
// `readonly`, written by the same four refresh hooks — see the note on
// `actual_revenue` above.
// Definition: total members enrolled.
num_sent: Field.number({
group: 'metrics',
label: 'Number Sent',
min: 0,
readonly: true,
}),

num_responses: Field.number({
group: 'metrics',
label: 'Number of Responses',
min: 0,
readonly: true,
}),

num_leads: Field.number({
group: 'metrics',
label: 'Number of Leads',
min: 0,
readonly: true,
}),

num_converted_leads: Field.number({
group: 'metrics',
label: 'Converted Leads',
min: 0,
readonly: true,
}),

num_opportunities: Field.number({
group: 'metrics',
label: 'Opportunities Created',
min: 0,
readonly: true,
}),

num_won_opportunities: Field.number({
group: 'metrics',
label: 'Won Opportunities',
min: 0,
readonly: true,
}),

// Calculated Metrics (Formula Fields)
Expand Down
Loading
Loading