From 4e0e070b411eda06b966e30a614684c9d3c796d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:52:30 +0000 Subject: [PATCH 1/5] fix(intake): route web leads and cases and flag web duplicates (runAs: 'system') lead_auto_assign, case_auto_assign and lead_duplicate_check read the rep / agent pool and existing contacts and leads as the anonymous form caller, whose guest_portal grant reads nothing, so every web lead and case landed ownerless and a web duplicate was never flagged. Since objectstack#22437 (18.0.0-next.0) the form door answers the new id only, so the three hooks now elevate their reads (ruling 6076828219 item 4, B). Each read is pinned to the organization the writer named on the new record (AGENTS.md rule 10). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- src/sales/objects/lead.hook.ts | 100 +++++++++++++++++++----- src/service/objects/_case-assignment.ts | 56 ++++++++++--- 2 files changed, 123 insertions(+), 33 deletions(-) diff --git a/src/sales/objects/lead.hook.ts b/src/sales/objects/lead.hook.ts index 7f9e40ad..cfbf7203 100644 --- a/src/sales/objects/lead.hook.ts +++ b/src/sales/objects/lead.hook.ts @@ -49,13 +49,41 @@ import type { HookApi } from './_hook-api'; * AFTER `lead_automation` (200), whose guest branch strips a client-spoofed * `owner_id` from anonymous Web-to-Lead submissions. Assigning first and being * stripped afterwards lands every web-to-lead ownerless — the exact case this - * hook exists for. + * hook exists for. It is also what keeps the elevation below from letting an + * anonymous client choose the owner: the strip runs first, so this hook only + * ever writes the pool's choice. + * + * ## `runAs: 'system'` (#2014, AGENTS.md rule 9), and the organization pin + * + * Web-to-lead is the intake this hook exists for, and as the caller it never + * reached the pool: the anonymous form writes under `guest_portal`, which + * reads nothing, so the pool read came back empty and every web lead landed + * ownerless with reps staffed (measured on 17.7.0 and again on 18.0.0-next.0). + * Who works an enquiry is the business's routing, not the submitter's + * privilege, so the hook elevates. Elevation reaches the two READS only: the + * owner is still stamped on the payload in flight (no write of its own, no + * transfer grant), and a person's own insert never gets here. + * + * What elevation reads never reaches the submitter. Maintainer ruling on + * #2014 (item 4, B): the platform fixed the door first — since + * objectstack#22437 (18.0.0-next.0) the anonymous form answers the new + * record's id and nothing else, so the owner chosen here stays inside the CRM. + * `test/hooks-runtime.test.ts` pins that answer through the real door. + * + * AGENTS.md rule 10: the pool and the load counts are organization-NEUTRAL + * predicates, so both are pinned to the new lead's `organization_id` when the + * writer named one (a system import into one organization of several). The + * anonymous form names none here — the platform stamps the organization after + * the beforeInsert hooks (ADR-0131) — and an organization-less insert lands + * only on an install with exactly one organization (with two, the platform + * refuses it), so an unpinned read has no other organization to reach. */ const leadAutoAssignHook: Hook = { name: 'lead_auto_assign', object: 'crm_lead', events: ['beforeInsert'], priority: 250, + runAs: 'system', description: 'Assign ownerless new leads to the least-loaded sales rep.', handler: async (ctx: HookContext) => { const { input } = ctx; @@ -67,17 +95,22 @@ const leadAutoAssignHook: Hook = { const api = ctx.api as HookApi | undefined; if (!api) return; + // The rule-10 pin on both reads below: the organization the writer named + // on the new lead, when it named one (see the note above the hook). + const organizationId = + typeof input.organization_id === 'string' && input.organization_id !== '' + ? input.organization_id + : undefined; + const inOrganization: Record = organizationId ? { organization_id: organizationId } : {}; + // Auto-assignment is a best-effort ENHANCEMENT — it must NEVER block lead - // creation. In particular an anonymous Web-to-Lead submission runs under the - // public-form grant, which permits only create/read-back on crm_lead and - // DENIES `find` on sys_user_position; letting that denial propagate would - // reject the whole insert and break the public form. So the pool lookup + - // load balancing are wrapped: on ANY error (permission, etc.) we log and - // leave the lead ownerless (a manager / the lead_assignment flow routes it). + // creation. So the pool lookup + load balancing are wrapped: on ANY error + // the lead is still captured, ownerless (a manager / the lead_assignment + // flow routes it). try { // Rep pool = holders of the sales_rep position. const holders = await api.object('sys_user_position').find({ - where: { position: 'sales_rep' }, fields: ['user_id'], top: 1000, + where: { position: 'sales_rep', ...inOrganization }, fields: ['user_id'], top: 1000, }); const repIds = Array.from( new Set( @@ -93,7 +126,7 @@ const leadAutoAssignHook: Hook = { let bestCount = Infinity; for (const repId of repIds) { const openCount = await api.object('crm_lead').count({ - where: { owner_id: repId, is_converted: false }, + where: { owner_id: repId, is_converted: false, ...inOrganization }, }); if (openCount < bestCount) { bestCount = openCount; @@ -102,10 +135,9 @@ const leadAutoAssignHook: Hook = { } if (best) input.owner_id = best; } catch { - // Swallow: auto-assignment must never block the insert. Common case is the - // anonymous public-form context, which can't read sys_user_position — the - // lead is still captured, ownerless, for downstream routing. (No `console` - // here — the L2 hook sandbox doesn't define it.) + // Swallow: auto-assignment must never block the insert — the lead is + // still captured, ownerless, for downstream routing. (No `console` here — + // the L2 hook sandbox doesn't define it.) } }, }; @@ -530,12 +562,35 @@ const leadHook: Hook = { * ⚠️ Priority 300 — after `lead_automation` (200), whose guest branch strips * client-supplied `duplicate_*` from anonymous submissions. Running earlier * would let a spoofed `duplicate_status` switch this hook off. + * + * ## `runAs: 'system'` (#2014, AGENTS.md rule 9), and the organization pin + * + * Whether an address is already in the CRM is the CRM's fact, not the + * writer's privilege. As the caller, job 2 saw only what the writer may read: + * the anonymous form reads nothing, so a web duplicate always landed + * unflagged, and a rep's scan missed a colleague's contact or lead. So the + * hook elevates. Elevation reaches the two insert-time reads, the only + * `ctx.api` calls here; what it writes is the verdict and the link on the NEW + * lead, nothing else. + * + * The verdict never reaches an anonymous submitter: ruling #2014 item 4 (B) + * waited for objectstack#22437, and since 18.0.0-next.0 the form answers the + * new record's id and nothing else, so no public form is an email-existence + * oracle. `test/hooks-runtime.test.ts` pins that answer through the real door. + * + * AGENTS.md rule 10: both reads are pinned to the new lead's + * `organization_id` when the writer named one, so a match is never found in + * another organization. A person's write is confined by their own tenant, + * which the elevated `ctx.api` keeps; the anonymous form names none here, and + * lands only on an install with exactly one organization (see + * `lead_auto_assign`). */ const leadDuplicateCheckHook: Hook = { name: 'lead_duplicate_check', object: 'crm_lead', events: ['beforeInsert', 'beforeUpdate'], priority: 300, + runAs: 'system', description: 'Normalize lead email and company match key; flag a re-captured address as a suspected duplicate of the record it repeats.', handler: async (ctx: HookContext) => { @@ -733,15 +788,20 @@ const leadDuplicateCheckHook: Hook = { return best; }; + // The rule-10 pin on both reads below (see the note above the hook). + const organizationId = + typeof input.organization_id === 'string' && input.organization_id !== '' + ? input.organization_id + : undefined; + const inOrganization: Record = organizationId ? { organization_id: organizationId } : {}; + // Best-effort ENHANCEMENT, never a gate on the write — identical to - // `lead_auto_assign` above. An anonymous Web-to-Lead submission runs under - // the `guest_portal` grant, which is INSERT-only on `crm_lead` and denies - // reads outright; letting that denial propagate would reject the very - // submission this feature exists to accept. A guest's duplicate therefore - // lands unflagged rather than not landing at all. + // `lead_auto_assign` above. A failed read must not reject the very + // submission this feature exists to accept, so the duplicate lands + // unflagged rather than not landing at all. try { const contacts = await api.object('crm_contact').find({ - where: { email }, + where: { email, ...inOrganization }, fields: ['id', 'created_at'], top: SCAN_LIMIT, }); @@ -758,7 +818,7 @@ const leadDuplicateCheckHook: Hook = { // address — pointing at the locked lead instead would name a record // nobody can work. const leads = await api.object('crm_lead').find({ - where: { email, is_converted: false }, + where: { email, is_converted: false, ...inOrganization }, fields: ['id', 'created_at'], top: SCAN_LIMIT, }); diff --git a/src/service/objects/_case-assignment.ts b/src/service/objects/_case-assignment.ts index 18ec9fb5..de51ee94 100644 --- a/src/service/objects/_case-assignment.ts +++ b/src/service/objects/_case-assignment.ts @@ -161,12 +161,35 @@ export const CLAIMABLE_TARGET_STATUSES = [ * * ## Best-effort, always * - * Auto-assignment is an ENHANCEMENT and must never reject the insert. An - * anonymous Web-to-Case submission runs under the public-form grant, which - * permits create/read-back on `crm_case` and DENIES `find` on - * `sys_user_position`; letting that denial propagate would 403 the whole - * submission and break the public support form. So every read is wrapped and - * ANY failure leaves the case ownerless for the triage view to surface. + * Auto-assignment is an ENHANCEMENT and must never reject the insert, so every + * read is wrapped and ANY failure leaves the case ownerless for the triage + * view to surface. + * + * ## `runAs: 'system'` (#2014, AGENTS.md rule 9), and the organization pin + * + * Web-to-case is the intake this hook exists for (#596), and as the caller it + * never reached the pool: the anonymous form writes under `guest_portal`, + * which reads nothing, so the read came back empty and every web case landed + * in triage with agents staffed (measured on 17.7.0 and again on + * 18.0.0-next.0). Who works a case is the business's routing, not the + * submitter's privilege, so the hook elevates. Elevation reaches the two READS + * only: the owner is still stamped on the payload in flight (door ① above, no + * transfer grant), and the strip at priority 200 runs first, so an anonymous + * client cannot choose the owner. + * + * What elevation reads never reaches the submitter. Maintainer ruling on + * #2014 (item 4, B): the platform fixed the door first — since + * objectstack#22437 (18.0.0-next.0) the anonymous form answers the new + * record's id and nothing else. `test/case-assignment.test.ts` pins that + * answer through the real door. + * + * AGENTS.md rule 10: the pool and the load counts are pinned to the new + * case's `organization_id` when the writer named one (a system import into + * one organization of several). The anonymous form names none here — the + * platform stamps the organization after the beforeInsert hooks (ADR-0131) — + * and an organization-less insert lands only on an install with exactly one + * organization (with two, the platform refuses it), so an unpinned read has + * no other organization to reach. * * @param hookName registry name for the hook (metadata only — never read by * the body, which the sandbox would not let it be). @@ -177,6 +200,7 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook object: 'crm_case', events: ['beforeInsert'], priority: 250, + runAs: 'system', description: 'Assign ownerless new cases to the least-loaded service agent.', handler: async (ctx: HookContext) => { const { input } = ctx; @@ -189,12 +213,20 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook const api = ctx.api as HookApi | undefined; if (!api) return; + // The rule-10 pin on both reads below: the organization the writer + // named on the new case, when it named one. + const organizationId = + typeof input.organization_id === 'string' && input.organization_id !== '' + ? input.organization_id + : undefined; + const inOrganization: Record = organizationId ? { organization_id: organizationId } : {}; + try { // The pool = holders of the `service_agent` position. This literal is // the exported SERVICE_AGENT_POSITION; the sandbox forbids reading the // constant here, and `test/case-assignment.test.ts` pins the parity. const holders = await api.object('sys_user_position').find({ - where: { position: 'service_agent' }, fields: ['user_id'], top: 1000, + where: { position: 'service_agent', ...inOrganization }, fields: ['user_id'], top: 1000, }); const agentIds = Array.from( new Set( @@ -214,7 +246,7 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook let bestCount = Infinity; for (const agentId of agentIds) { const openCount = await api.object('crm_case').count({ - where: { owner_id: agentId, status: { $nin: ['resolved', 'closed'] } }, + where: { owner_id: agentId, status: { $nin: ['resolved', 'closed'] }, ...inOrganization }, }); if (openCount < bestCount) { bestCount = openCount; @@ -223,11 +255,9 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook } if (best) input.owner_id = best; } catch { - // Swallow: assignment must never block case creation. The common case - // is the anonymous public-form context, which cannot read - // `sys_user_position` — the case is still captured, ownerless, and the - // triage view surfaces it. (No `console` here — the L2 hook sandbox - // does not define one.) + // Swallow: assignment must never block case creation — the case is + // still captured, ownerless, and the triage view surfaces it. (No + // `console` here — the L2 hook sandbox does not define one.) } }, }; From e8db0c98e61664e7c384a6c5169a33436538a454 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:06:39 +0000 Subject: [PATCH 2/5] test(intake): pin web intake routed, flagged and answered with the id only Flip the pins that recorded F9 as a defect to the business fact: a web case and a web lead are round-robined into their pool, a web duplicate is flagged, and a planted owner gives way to the pool's choice. Through the platform's own anonymous door, both public forms answer the new id and nothing else. Two-organization cases pin each elevated read to the organization the writer named. Comments that said the form cannot read the pool are corrected. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- src/service/views/case.view.ts | 3 +- test/case-assignment.test.ts | 97 +++++++++++++++------- test/demo-staffing.test.ts | 6 +- test/flow-record-change.test.ts | 7 +- test/guest-submission-sanitisation.test.ts | 23 ++--- test/helpers/verify-stack.ts | 13 ++- test/hooks-runtime-service.test.ts | 51 ++++++++---- test/hooks-runtime.test.ts | 77 +++++++++++++++-- test/ownership-model.test.ts | 4 +- 9 files changed, 197 insertions(+), 84 deletions(-) diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index 768af9b5..2978409b 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -170,8 +170,7 @@ export const CaseViews = defineView({ * NORM rather than an edge case: `sys_user_position` membership is runtime * data, so on a fresh org — and any time every agent has been unassigned * from the position — an inbound web-to-case lands ownerless. Best-effort - * assignment also stands down on a permission denial (the anonymous - * public-form context cannot read `sys_user_position` at all). + * assignment also stands down when its pool read fails. * * Those cases used to be invisible: no view filtered on the absence of an * owner, so an unowned case appeared in `All Cases` with a blank Owner diff --git a/test/case-assignment.test.ts b/test/case-assignment.test.ts index 78d5d389..4ce4e045 100644 --- a/test/case-assignment.test.ts +++ b/test/case-assignment.test.ts @@ -329,20 +329,50 @@ describe('case_auto_assign', () => { } }); - it('never rejects the insert when the pool read is DENIED', async () => { - // The anonymous Web-to-Case grant permits create/read-back on `crm_case` and - // denies `find` on `sys_user_position`. Propagating that denial would 403 - // the whole submission and break the public support form — so the case is - // captured ownerless and lands in triage instead. - const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 0 }]); + it('routes an anonymous submission like any other intake — the reads are elevated (#2014)', async () => { + // The anonymous form's own grant (`guest_portal`) reads nothing, so as the + // caller the pool came back empty and every web case landed in triage. + // The hook declares `runAs: 'system'`: the reads are the business's, and + // the least-loaded agent gets the case. + const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 1 }, { open: 0 }]); try { + const asTheForm = { publicFormGrant: { object: 'crm_case' }, permissions: ['guest_portal'], anonymous: true }; + const guestRead = await verify.kernel.getService('objectql') + .find('sys_user_position', { where: { position: SERVICE_AGENT_POSITION }, context: asTheForm }) + .catch((error: unknown) => error); + expect(JSON.stringify(guestRead ?? null), 'the form’s own grant reads the pool, so this case proves nothing') + .not.toContain(pool.people[1]!.id); const written = await guestInsert(verify, 'crm_case', { subject: 'Anonymous submission', description: 'Help.' }); - expect((await storedCase(written.id)).owner_id ?? null).toBeNull(); + expect((await storedCase(written.id)).owner_id, 'a web case was not routed to the least-loaded agent') + .toBe(pool.people[1]!.id); } finally { await pool.release(); } }); + it('stays inside the new case’s organization (AGENTS.md rule 10)', async () => { + // Elevated, the pool and the load counts are organization-neutral, so the + // hook pins both to the organization the writer named on the case. Two + // organizations, one agent each: the other organization's agent is the + // LESS loaded one, so an unpinned read picks them. On a boot of its own + // (and a build of its own), as the escalation case below explains. + const twoOrgs = await bootStack(await buildArtifactAgain(), bootOptions()); + const orgs = await twoOrgs.seed('sys_organization', [ + { name: 'Intake Org', slug: 'intake-org' }, { name: 'Other Org', slug: 'other-org' }, + ]); + const [own, other] = [String(orgs[0]!.id), String(orgs[1]!.id)]; + const ownAgent = await signUpPerson(twoOrgs, 'own.agent@case-assignment.test', { name: 'Own Agent' }); + const otherAgent = await signUpPerson(twoOrgs, 'other.agent@case-assignment.test', { name: 'Other Agent' }); + await twoOrgs.seed('sys_user_position', [ + { user_id: ownAgent.id, position: SERVICE_AGENT_POSITION, organization_id: own }, + { user_id: otherAgent.id, position: SERVICE_AGENT_POSITION, organization_id: other }, + ]); + await twoOrgs.seed('crm_case', [{ subject: 'Own load', description: 'x', status: 'new', owner_id: ownAgent.id, organization_id: own }]); + const [kase] = await twoOrgs.seed('crm_case', [{ subject: 'Org intake', description: 'x', status: 'new', organization_id: own }]); + const [after] = await twoOrgs.rows('crm_case', { id: kase!.id }); + expect(after!.owner_id, 'the intake crossed into another organization’s pool').toBe(ownAgent.id); + }, 120_000); + it('never blocks intake, whoever writes it', async () => { // The `!ctx.api` stand-down has no door on the shipped app — the engine // hands every insert a read door — so what is pinned is the property it @@ -379,36 +409,41 @@ describe('the guest strip and the assignment are ordered, not merely coexisting' it('a guest submission’s spoofed owner is stripped before anything reads it', async () => { // The shape a web-to-case submission actually takes, including the spoofed - // owner a public form can always post. Nulled, not removed (#1133). - const written = await guestInsert(verify, 'crm_case', { - subject: 'Spoofed', description: 'x', owner_id: (await signUpPerson(verify, `victim.${++k}@case-assignment.test`)).id, - }); - const kase = await storedCase(written.id); - expect(kase.owner_id ?? null, 'the guest strip stopped removing the spoofed owner').toBeNull(); - expect(kase.origin, 'guest defaults regressed').toBe('web'); + // owner a public form can always post. Nulled, not removed (#1133) — and + // the elevated round-robin after it writes the POOL's choice, never the + // submitter's: the victim is a member of the pool, and the more loaded one. + const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 1 }, { open: 0 }]); + try { + const written = await guestInsert(verify, 'crm_case', { subject: 'Spoofed', description: 'x', owner_id: pool.people[0]!.id }); + const kase = await storedCase(written.id); + expect(kase.owner_id, 'an anonymous client chose the owner').toBe(pool.people[1]!.id); + expect(kase.origin, 'guest defaults regressed').toBe('web'); + } finally { + await pool.release(); + } }); /** - * ⚠️ MEASURED DEFECT — reported as a finding on this card, pinned here so the - * fix is noticed. - * - * The round-robin exists for exactly this submission (the header: a - * web-to-case case "used to land ownerless with nobody accountable"), and the - * strip-then-assign order above is what was meant to hand it to an agent. - * But the anonymous form door writes under the `guest_portal` grant, which - * cannot read the agent pool — the hook's own stand-down, two describes up — - * so the assignment never runs for a web submission at all. Measured on - * 17.7.0, under the form door's own execution context: with an agent staffed, - * the submission lands unowned. + * The acceptance of #2014's F9 at the platform's own anonymous door, + * `POST /forms/:slug/submit` (`verify.api` serves it since 18.0.0-next.0). + * The round-robin exists for exactly this submission, and it reaches the + * pool elevated; what elevation found must not reach the submitter. Since + * objectstack#22437 the door answers the new record's id and nothing else + * (maintainer ruling on #2014, item 4 B: the platform fixed the door first). */ - it('⚠️ a web-to-case submission is never round-robined, even with an agent staffed (measured defect)', async () => { + it('a web-to-case submission is round-robined, and the anonymous answer carries only its id (#2014)', async () => { const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 0 }]); try { - const written = await guestInsert(verify, 'crm_case', { subject: 'Web submission', description: 'Printer on fire.' }); - expect( - (await storedCase(written.id)).owner_id ?? null, - 'a web submission was assigned to the agent — the defect is fixed: rewrite this case to pin the hand-off', - ).toBeNull(); + const res = await verify.api('/forms/support/submit', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subject: 'Web submission', description: 'Printer on fire.', priority: 'high' }), + }); + expect(res.status).toBe(201); + const answer = await res.json() as Rec; + expect(Object.keys(answer), 'the anonymous answer carries more than the new id').toEqual(['id']); + const kase = await storedCase(String(answer.id)); + expect(kase.origin, 'not the guest submission this case is about').toBe('web'); + expect(kase.owner_id, 'a web submission was not routed to the staffed agent').toBe(pool.people[0]!.id); } finally { await pool.release(); } diff --git a/test/demo-staffing.test.ts b/test/demo-staffing.test.ts index e6aab7a7..562e0fb2 100644 --- a/test/demo-staffing.test.ts +++ b/test/demo-staffing.test.ts @@ -424,9 +424,9 @@ describe('the case-routing pools actually route (what this staffing lights)', () }; it('round-robins an ownerless case onto the demo service agent (#596)', async () => { - // Ownerless intake that reaches the pool: a case the system writes (an - // email-to-case integration, an import). An anonymous web form's grant - // cannot read the pool, so its cases wait in the triage queue instead. + // Ownerless intake: a case the system writes (an email-to-case + // integration, an import). An anonymous web form's case reaches the same + // pool, read elevated (#2014; pinned in case-assignment.test.ts). const [kase] = await verify.seed('crm_case', [{ subject: 'Email-to-case: cannot sign in', description: 'Forwarded from support@.', status: 'new', }]); diff --git a/test/flow-record-change.test.ts b/test/flow-record-change.test.ts index d7894c83..81762fe4 100644 --- a/test/flow-record-change.test.ts +++ b/test/flow-record-change.test.ts @@ -615,9 +615,10 @@ describe('record-change flows under a user-less trigger (#684)', () => { expect(String(run?.error ?? '')).not.toContain('[runAs]'); const [stored] = await verify.rows('crm_lead', { id: lead.id }); expect(stored!.next_followup_date, 'hot lead got no SLA date').toBeTruthy(); - // The anonymous form grant cannot read the rep pool, so `lead_auto_assign` - // leaves a web lead unowned (its documented stand-down). - expect(stored!.owner_id).toBeNull(); + // `lead_auto_assign` reads the rep pool elevated (#2014), so the web lead + // lands on the file's one rep — and the hot-lead alert reaches them. + expect(stored!.owner_id, 'a web lead was not routed to the rep').toBe(rep.id); + expect(await notified(rep.id, 'lead_routing', `/crm_lead/${lead.id}`), 'the web lead alerted nobody').toHaveLength(1); }); it('lead_assignment alerts the owner of an integration-written lead', async () => { diff --git a/test/guest-submission-sanitisation.test.ts b/test/guest-submission-sanitisation.test.ts index 37962ba1..8d8a938a 100644 --- a/test/guest-submission-sanitisation.test.ts +++ b/test/guest-submission-sanitisation.test.ts @@ -204,15 +204,12 @@ describe('crm_case — guest submission sanitisation', () => { ).not.toHaveProperty(retired); } - // The planted owner is gone. `case_auto_assign` runs after this strip, and - // only on a case the strip left ownerless — but a public-form submission - // writes under the `guest_portal` grant, which cannot read the agent pool, - // so the assignment stands down and the case lands unowned, for the triage - // queue (`case_unassigned_triage_sharing`). Measured under the anonymous - // door's own execution context; an empty context (which no door produces) - // read the pool and placed the case on the agent. + // The planted owner is gone. `case_auto_assign` runs after this strip, on + // the case the strip left ownerless, and reads the agent pool elevated + // (#2014) — so the case is owned by the real agent: a POSITIVE value, not + // an absence. expect(stored.owner_id).not.toBe('planted_user'); - expect(stored.owner_id).toBeNull(); + expect(stored.owner_id).toBe(id.agent); }, 60_000); it('derives is_closed from status rather than letting a guest state it', async () => { @@ -297,12 +294,10 @@ describe('crm_lead — guest submission sanitisation', () => { // submission — and the link beside it can name any record id they care to // guess. // - // What this case does NOT assert is that the dedupe then ran and wrote its - // own `suspected` verdict. It cannot: an anonymous submission holds the - // `guest_portal` grant, which is INSERT-only on `crm_lead` and denies the - // reads that check needs, so `lead_duplicate_check` swallows the denial by - // design and a guest's duplicate lands unflagged. Asserting `suspected` here - // would pin a fiction. The security property is that the guest's own verdict + // What this case does NOT assert is a verdict of the dedupe's own: the + // address repeats nothing on this stack, so the scan (elevated since #2014, + // and pinned flagging a real web duplicate in hooks-runtime-service) finds + // nothing to write. The security property is that the guest's own verdict // does not survive, and that is what is measured. const second = await insertAs(GUEST, 'crm_lead', { first_name: 'Grace', last_name: 'Hopper', company: 'Mark II', diff --git a/test/helpers/verify-stack.ts b/test/helpers/verify-stack.ts index 53ece834..2902ff96 100644 --- a/test/helpers/verify-stack.ts +++ b/test/helpers/verify-stack.ts @@ -254,13 +254,12 @@ export const predicateUpdate = async ( * branches key on. The platform's anonymous door is `POST /forms/:slug/submit` * (`@objectstack/rest` `registerFormEndpoints`), which writes under exactly * this execution context: the form's one-object grant, the `guest_portal` - * permission set, `anonymous`. The handle's dispatcher does not serve that - * route (measured on 17.7.0: `ENDPOINT_NOT_FOUND`), `hooks.run` needs a - * signed-in person, `seed` is the most trusted caller there is, and an - * unauthenticated `POST /api/v1/data/` answers 401, so the handle - * offers no way to reach that branch at all. The door also drops every key the - * form does not collect; a fixture that passes another key is modelling a form - * that collects it. + * permission set, `anonymous`. The handle's dispatcher did not serve that + * route on 17.7.0 (`ENDPOINT_NOT_FOUND`); since 18.0.0-next.0 `stack.api` + * reaches it, and a case about the door's ANSWER goes through it (#2014). This + * helper stays for the cases about what the hooks STORE: the door also drops + * every key the form does not collect, so a fixture that passes another key is + * modelling a form that collects it. */ export const guestInsert = (stack: VerifyStack, object: string, doc: Row): Promise => stack.kernel.getService('objectql').insert(object, doc, { diff --git a/test/hooks-runtime-service.test.ts b/test/hooks-runtime-service.test.ts index 7a4b501c..a67fd370 100644 --- a/test/hooks-runtime-service.test.ts +++ b/test/hooks-runtime-service.test.ts @@ -144,8 +144,11 @@ describe('case_sla_defaults', () => { // The branch OVERWRITES with a safe value; it does not remove the key // (#1133) — `delete` on a hook's `input` is a silent no-op through the real // engine. So the stored values are what is asserted. + // The planted owner is gone: the elevated round-robin after the strip + // writes the pool's choice (the agent), never the submitter's (#2014). + expect(row.owner_id, 'the planted owner survived, or the web case was not routed').toBe(agent.id); for (const [stripped, safe] of [ - ['owner_id', null], ['internal_notes', null], ['resolution', null], + ['internal_notes', null], ['resolution', null], // The flag AND the prose explaining it (#1296): a case stating an escalation // reason while not escalated contradicts itself in one field group. ['is_escalated', false], ['escalation_reason', null], @@ -1077,7 +1080,7 @@ describe('lead_automation', () => { const written = await guestInsert(verify, 'crm_lead', { first_name: 'Web', last_name: `Visitor ${++k}`, email: `visitor${k}@hooks-runtime-service.test`, company: 'FromWebForm', is_converted: true, converted_account: account.id, converted_date: '2020-01-01', - owner_id: rep.id, + owner_id: manager.id, }); const lead = await stored('crm_lead', written.id); expect(lead.lead_source).toBe('web'); @@ -1085,9 +1088,12 @@ describe('lead_automation', () => { // Overwritten with a safe value rather than removed (#1133) — see the // matching note on `case_sla_defaults` above. expect(lead.is_converted, 'public form kept is_converted').toBe(false); - for (const stripped of ['converted_account', 'converted_contact', 'converted_opportunity', 'converted_date', 'owner_id']) { + for (const stripped of ['converted_account', 'converted_contact', 'converted_opportunity', 'converted_date']) { expect(lead[stripped] ?? null, `public form kept ${stripped}`).toBeNull(); } + // The planted owner (not a rep) is gone: the elevated round-robin after the + // strip writes the pool's choice, never the submitter's (#2014). + expect(lead.owner_id, 'public form kept owner_id, or the web lead was not routed').toBe(rep.id); }); it('locks identity fields on a converted lead but leaves notes editable', async () => { @@ -1240,18 +1246,30 @@ describe('lead_duplicate_check', () => { expect(lead.duplicate_status ?? null).toBeNull(); }); - it('writes nothing and throws nothing when the lookup is denied (anonymous Web-to-Lead)', async () => { - // A guest can INSERT on crm_lead and read NOTHING. Propagating that denial - // would reject the very submission this feature exists to accept — the - // duplicate lands unflagged rather than not landing at all. + it('flags an anonymous Web-to-Lead duplicate too — the scan is elevated (#2014)', async () => { + // A guest can INSERT on crm_lead and read NOTHING, so as the caller a web + // duplicate always landed unflagged. Whether an address is known is the + // CRM's fact; what the form answers the guest is pinned in hooks-runtime. const address = fresh(); - await leadOf({ email: address }); + const first = await leadOf({ email: address }); const written = await guestInsert(verify, 'crm_lead', { first_name: 'Ada', last_name: 'Guest', company: 'Acme', email: address, }); const lead = await stored('crm_lead', written.id); - expect(lead.email).toBe(address); - expect(lead.duplicate_status ?? null).toBeNull(); + expect(lead.duplicate_status).toBe('suspected'); + expect(lead.duplicate_of_lead).toBe(first.id); + }); + + it('finds a colleague’s record the writing rep cannot read (#2014)', async () => { + // As the caller a rep's scan saw only what the rep may read, so a repeat of + // a manager's contact went unflagged. The link names a record on the new + // lead; the suspected-duplicates queue is where the pair is compared. + const address = fresh(); + const [theirs] = await verify.seed('crm_contact', [{ + first_name: 'Ada', last_name: 'Theirs', email: address, crm_account: (await accountOf({ owner_id: manager.id })).id, owner_id: manager.id, + }]); + expect(await verify.rows('crm_contact', { id: theirs!.id }, as(rep)), 'the rep can read it, so this case proves nothing').toEqual([]); + expect((await repLead({ email: address })).duplicate_of_contact).toBe(theirs!.id); }); it('runs after lead_automation, so a guest cannot spoof a verdict past the strip', async () => { @@ -1259,8 +1277,8 @@ describe('lead_duplicate_check', () => { // client-supplied `duplicate_status: 'confirmed'` that lead_automation's // guest branch is there to null, and stand down. Nulled rather than removed // (#1133): `lead_duplicate_check` stands down on a NON-BLANK verdict and its - // own `isBlank` counts `null` as blank. (The check itself then cannot read - // anything under the anonymous grant — the case above.) + // own `isBlank` counts `null` as blank. (The address is fresh, so the + // elevated scan finds nothing to flag either.) const check = (leadHooks as Rec[]).find((h) => h.name === 'lead_duplicate_check')!; const automation = (leadHooks as Rec[]).find((h) => h.name === 'lead_automation')!; expect(check.priority).toBeGreaterThan(automation.priority); @@ -1278,14 +1296,13 @@ describe('lead_duplicate_check', () => { }); }); -describe('lead_auto_assign — permission resilience', () => { - it('never throws when the rep-pool lookup is denied (anonymous Web-to-Lead)', async () => { - // The public-form grant denies `find` on sys_user_position. The hook must - // swallow that and leave the lead ownerless — NOT reject the insert. +describe('lead_auto_assign — the anonymous door', () => { + it('routes an anonymous Web-to-Lead submission to the rep pool (#2014)', async () => { + // The public-form grant reads nothing; the hook reads the pool elevated. const written = await guestInsert(verify, 'crm_lead', { first_name: 'Web', last_name: `Form ${++k}`, company: 'FromWebForm', email: `form${k}@hooks-runtime-service.test`, }); - expect((await stored('crm_lead', written.id)).owner_id ?? null).toBeNull(); + expect((await stored('crm_lead', written.id)).owner_id).toBe(rep.id); }); }); diff --git a/test/hooks-runtime.test.ts b/test/hooks-runtime.test.ts index 9e616b01..9f882bc9 100644 --- a/test/hooks-runtime.test.ts +++ b/test/hooks-runtime.test.ts @@ -313,12 +313,79 @@ describe('lead_auto_assign', () => { expect((await stored('crm_lead', written!.id)).owner_id).toBe(rep.id); }); - it('never throws when the rep-pool lookup is denied (anonymous Web-to-Lead)', async () => { - // The public-form grant denies `find` on sys_user_position. The hook must - // swallow that and leave the lead ownerless — NOT reject the insert. + it('routes an anonymous Web-to-Lead submission into the rep pool (#2014)', async () => { + // The form's own grant reads nothing, so as the caller the pool read came + // back empty and every web lead landed ownerless. The hook declares + // `runAs: 'system'`: who works an enquiry is the business's routing. const written = await guestInsert(verify, 'crm_lead', lead({ company: 'FromWebForm' })); const row = await stored('crm_lead', written.id); - expect(row.company).toBe('FromWebForm'); - expect(row.owner_id).toBeNull(); // ownerless, but the insert proceeded + expect(row.lead_source, 'not the guest submission this case is about').toBe('web'); + expect([rep.id, rep2.id], 'a web lead was not routed to the rep pool').toContain(row.owner_id); }); + + /** + * The acceptance of #2014's F9 at the platform's own anonymous door, + * `POST /forms/:slug/submit` (`verify.api` serves it since 18.0.0-next.0). + * Both elevated hooks find something about this submission — an owner, and + * the contact it repeats — and neither finding may reach the submitter: + * since objectstack#22437 the door answers the new record's id and nothing + * else, so no public form is an email-existence oracle (maintainer ruling on + * #2014, item 4 B: the platform fixed the door first). + */ + it('a web lead is routed and flagged, and the anonymous answer carries only its id (#2014)', async () => { + const address = `known${++k}@hooks-runtime.test`; + const [known] = await verify.seed('crm_contact', [{ + first_name: 'Ada', last_name: 'Known', email: address, crm_account: (await accountOf()).id, owner_id: rep.id, + }]); + const res = await verify.api('/forms/contact-us/submit', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ first_name: 'Ada', last_name: 'Again', email: address.toUpperCase(), company: 'Web Co', description: 'Back again.' }), + }); + expect(res.status).toBe(201); + const answer = await res.json() as Rec; + expect(Object.keys(answer), 'the anonymous answer carries more than the new id').toEqual(['id']); + const row = await stored('crm_lead', String(answer.id)); + expect(row.lead_source, 'not the guest submission this case is about').toBe('web'); + expect([rep.id, rep2.id], 'a web lead was not routed to the rep pool').toContain(row.owner_id); + expect(row.duplicate_status, 'a web duplicate was not flagged').toBe('suspected'); + expect(row.duplicate_of_contact).toBe(known!.id); + }); + + it('both lead intake hooks stay inside the new lead’s organization (AGENTS.md rule 10)', async () => { + // Elevated, the rep pool, the load counts and the duplicate scan are + // organization-neutral, so each is pinned to the organization the writer + // named on the lead. Two organizations: the other one's rep is the LESS + // loaded, and the other one holds a contact at the same address — an + // unpinned read picks that rep and flags that contact. A boot (and a + // build) of its own: once an install holds two organizations, every + // system insert must name one. + const twoOrgs = await bootStack(await buildArtifactAgain(), bootOptions()); + const orgs = await twoOrgs.seed('sys_organization', [ + { name: 'Lead Org', slug: 'lead-org' }, { name: 'Other Org', slug: 'other-org' }, + ]); + const [own, other] = [String(orgs[0]!.id), String(orgs[1]!.id)]; + const ownRep = await signUpPerson(twoOrgs, 'own.rep@hooks-runtime.test', { name: 'Own Rep' }); + const otherRep = await signUpPerson(twoOrgs, 'other.rep@hooks-runtime.test', { name: 'Other Rep' }); + await twoOrgs.seed('sys_user_position', [ + { user_id: ownRep.id, position: 'sales_rep', organization_id: own }, + { user_id: otherRep.id, position: 'sales_rep', organization_id: other }, + ]); + await twoOrgs.seed('crm_lead', [lead({ owner_id: ownRep.id, organization_id: own })]); + const contactIn = async (organization_id: string, email: string) => { + const [acct] = await twoOrgs.seed('crm_account', [{ name: `Org Co ${++k}`, organization_id }]); + return (await twoOrgs.seed('crm_contact', [{ first_name: 'Ada', last_name: 'Org', email, crm_account: acct!.id, organization_id }]))[0]!; + }; + const ownContact = await contactIn(own, `own${++k}@hooks-runtime.test`); + await contactIn(other, `shared${++k}@hooks-runtime.test`); + const shared = `shared${k}@hooks-runtime.test`; + const intake = async (email: string) => { + const [written] = await twoOrgs.seed('crm_lead', [lead({ email, organization_id: own })]); + return (await twoOrgs.rows('crm_lead', { id: written!.id }))[0]!; + }; + const crossing = await intake(shared); + expect(crossing.owner_id, 'the intake crossed into another organization’s rep pool').toBe(ownRep.id); + expect(crossing.duplicate_status ?? null, 'the duplicate scan matched a contact in another organization').toBeNull(); + // Anti-vacuum: the scan does run on this boot, inside the organization. + expect((await intake(String(ownContact.email))).duplicate_of_contact, 'the scan found nothing at all').toBe(ownContact.id); + }, 120_000); }); diff --git a/test/ownership-model.test.ts b/test/ownership-model.test.ts index 03bae963..def2a354 100644 --- a/test/ownership-model.test.ts +++ b/test/ownership-model.test.ts @@ -435,8 +435,8 @@ describe('lead_auto_assign assigns the platform column', () => { it('writes owner_id, not a second column', async () => { // Genuinely ownerless intake: a row written by the system with no owner — // the posture an import or a seed load writes under (the platform's own - // seed context). A public-form submission does not reach the pool at all: - // its grant cannot read `sys_user_position`, so the hook stands down. + // seed context). A public-form submission reaches the same pool, read + // elevated (#2014; pinned in hooks-runtime.test.ts). const [written] = await verify.seed('crm_lead', [lead()]); const [stored] = await verify.rows('crm_lead', { id: written!.id }); expect(stored!.owner_id, 'did not assign the least-loaded rep on owner_id').toBe(repB.id); From 204e857823fb128ed93a18c42f111b84b63b8681 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:10:09 +0000 Subject: [PATCH 3/5] test(intake): the two-organization dedupe case uses the address it planted The shared address was computed after the fixture helper advanced the counter, so the intake never repeated the other organization's contact and the dedupe half of the case could not fail. Measured by ablation: with the organization pin removed it stayed green. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- test/hooks-runtime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hooks-runtime.test.ts b/test/hooks-runtime.test.ts index 9f882bc9..7a99210f 100644 --- a/test/hooks-runtime.test.ts +++ b/test/hooks-runtime.test.ts @@ -376,8 +376,8 @@ describe('lead_auto_assign', () => { return (await twoOrgs.seed('crm_contact', [{ first_name: 'Ada', last_name: 'Org', email, crm_account: acct!.id, organization_id }]))[0]!; }; const ownContact = await contactIn(own, `own${++k}@hooks-runtime.test`); - await contactIn(other, `shared${++k}@hooks-runtime.test`); - const shared = `shared${k}@hooks-runtime.test`; + const shared = `shared${++k}@hooks-runtime.test`; + await contactIn(other, shared); const intake = async (email: string) => { const [written] = await twoOrgs.seed('crm_lead', [lead({ email, organization_id: own })]); return (await twoOrgs.rows('crm_lead', { id: written!.id }))[0]!; From 0237c3d8480554b6a634d8c579e44ff0fc4eabaf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:14:01 +0000 Subject: [PATCH 4/5] docs(intake): web forms are routed and checked like any other intake The setup page said the anonymous web-to-case form cannot read the agent pool, and the leads page said a web duplicate lands unflagged; both stopped being true when the intake hooks elevated their reads. Both now say the visitor is told only that the submission was received. All three locales, plus the release note. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- .changeset/2014-web-intake-hooks.md | 5 +++++ content/docs/administration/setup.mdx | 2 +- content/docs/administration/setup.zh-Hans.mdx | 2 +- content/docs/administration/setup.zh-Hant.mdx | 2 +- content/docs/sales/leads.mdx | 2 +- content/docs/sales/leads.zh-Hans.mdx | 2 +- content/docs/sales/leads.zh-Hant.mdx | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/2014-web-intake-hooks.md diff --git a/.changeset/2014-web-intake-hooks.md b/.changeset/2014-web-intake-hooks.md new file mode 100644 index 00000000..1657bf45 --- /dev/null +++ b/.changeset/2014-web-intake-hooks.md @@ -0,0 +1,5 @@ +--- +'hotcrm': patch +--- + +Submissions from the public Web-to-Lead (Contact Us) and Web-to-Case (Support) forms are now routed like every other incoming lead and case: a web lead goes to the sales rep with the fewest open leads, and its new-lead alert reaches that rep; a web case goes to the service agent with the fewest open cases, instead of waiting in Unassigned — triage while agents are on the rota. A web enquiry from an address already in the CRM is now flagged as a suspected duplicate, and a sales rep's new lead is now flagged when it repeats a colleague's contact or lead the rep cannot see. The visitor who fills in a public form is still told only that the submission was received — never who it went to, or whether the address was already known. diff --git a/content/docs/administration/setup.mdx b/content/docs/administration/setup.mdx index de32ff9c..65144faa 100644 --- a/content/docs/administration/setup.mdx +++ b/content/docs/administration/setup.mdx @@ -113,7 +113,7 @@ Adjust if your support promise differs — the sixteen numbers live in `src/serv ### 12. Case routing — nothing to configure -**There is no Setup → Case Settings screen**, and there are no routing rules to write. An ownerless case — a web-to-case submission is the usual way one arrives — is assigned on insert by `case_auto_assign` (`src/service/objects/_case-assignment.ts`): it goes to whichever holder of the `service_agent` position has the **fewest open cases**, counting every case whose status is neither *Resolved* nor *Closed*. It is the same self-balancing round-robin leads get: least-loaded wins, not a rotation and not a rule matching product, region or origin; there is no territory routing and no queue. It runs **on insert only**, and only on a case that arrives with no owner — it never takes a case off an owner later, and it never fires on a case an agent creates, because the platform has already stamped that agent as the owner by the time the hook runs. It never blocks intake either: when the pool cannot be read at all — which is what the anonymous web-to-case form hits — the case is still captured, just ownerless. And if nobody holds `service_agent` the hook does nothing, so the case stays ownerless and waits in the **Unassigned — triage** tab on [Cases](/docs/service/cases). That tab is this hook's do-nothing path made visible, which makes staffing the position a behaviour change in itself: new cases stop arriving ownerless, and stop appearing there. **This is not the escalation hand-off**, which is a different pool at a different moment — that one rides on the transition to *Escalated* and moves the case to the least-loaded holder of `service_manager` (see [SLA & escalation](/docs/service/sla-and-escalation)). +**There is no Setup → Case Settings screen**, and there are no routing rules to write. An ownerless case — a web-to-case submission is the usual way one arrives — is assigned on insert by `case_auto_assign` (`src/service/objects/_case-assignment.ts`): it goes to whichever holder of the `service_agent` position has the **fewest open cases**, counting every case whose status is neither *Resolved* nor *Closed*. It is the same self-balancing round-robin leads get: least-loaded wins, not a rotation and not a rule matching product, region or origin; there is no territory routing and no queue. It runs **on insert only**, and only on a case that arrives with no owner — it never takes a case off an owner later, and it never fires on a case an agent creates, because the platform has already stamped that agent as the owner by the time the hook runs. It never blocks intake either: if the pool cannot be read, the case is still captured, just ownerless. A case from the anonymous web-to-case form is routed like any other — the visitor is only told the case was received, never who it went to. And if nobody holds `service_agent` the hook does nothing, so the case stays ownerless and waits in the **Unassigned — triage** tab on [Cases](/docs/service/cases). That tab is this hook's do-nothing path made visible, which makes staffing the position a behaviour change in itself: new cases stop arriving ownerless, and stop appearing there. **This is not the escalation hand-off**, which is a different pool at a different moment — that one rides on the transition to *Escalated* and moves the case to the least-loaded holder of `service_manager` (see [SLA & escalation](/docs/service/sla-and-escalation)). - [ ] Assign the `service_agent` position to every agent who should receive intake — that pool *is* the routing table - [ ] While nobody holds it, watch the **Unassigned — triage** tab on Cases: it lists exactly the cases this hook could not place diff --git a/content/docs/administration/setup.zh-Hans.mdx b/content/docs/administration/setup.zh-Hans.mdx index 7bcb6e24..36e70be0 100644 --- a/content/docs/administration/setup.zh-Hans.mdx +++ b/content/docs/administration/setup.zh-Hans.mdx @@ -113,7 +113,7 @@ description: 让 HotCRM 为你的团队做好生产准备的第一周任务。 ### 12. 工单路由 —— 没有需要配置的东西 -**不存在“系统设置 → 工单设置”这个屏幕**,也没有任何路由规则可写。一张无所有者的工单 —— 最常见的来路是网页转工单(web-to-case)提交 —— 会在插入时由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)分配:交给持有 `service_agent` 岗位的人中**未结工单最少**的那一位,这里的“未结”指状态既不是 *Resolved* 也不是 *Closed*。这和线索那一侧是同一种自平衡轮询:比的是谁负载最轻,而不是按次序轮换,也不是按产品、区域或来源匹配的规则;没有区域路由,也没有队列。它**只在插入时运行**,而且只处理到达时就没有所有者的工单 —— 它不会在之后把工单从某个人手里拿走,也不会在客服自己新建工单时触发,因为钩子跑起来之前平台已经把创建者盖成了所有者。它也从不阻断工单进入:当人员池根本读不到时(匿名网页转工单表单就是这种情形),工单照样被记录下来,只是无主。而如果没有人持有 `service_agent`,这个钩子什么也不做,工单就一直无主,停在[工单](/zh-Hans/docs/service/cases)页面的 **未分派 — 待分诊** 页签里。那个页签正是这个钩子“什么也不做”这条路径的可见化,所以给这个岗位配上人本身就是一次行为变化:新工单不再无主到达,也不再出现在那里。**这不是升级交接**:那是另一个人员池、另一个时机 —— 它挂在转为 *Escalated* 的状态迁移上,把工单交给持有 `service_manager` 岗位的人中负载最轻的那一位(参见 [SLA 与升级](/zh-Hans/docs/service/sla-and-escalation))。 +**不存在“系统设置 → 工单设置”这个屏幕**,也没有任何路由规则可写。一张无所有者的工单 —— 最常见的来路是网页转工单(web-to-case)提交 —— 会在插入时由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)分配:交给持有 `service_agent` 岗位的人中**未结工单最少**的那一位,这里的“未结”指状态既不是 *Resolved* 也不是 *Closed*。这和线索那一侧是同一种自平衡轮询:比的是谁负载最轻,而不是按次序轮换,也不是按产品、区域或来源匹配的规则;没有区域路由,也没有队列。它**只在插入时运行**,而且只处理到达时就没有所有者的工单 —— 它不会在之后把工单从某个人手里拿走,也不会在客服自己新建工单时触发,因为钩子跑起来之前平台已经把创建者盖成了所有者。它也从不阻断工单进入:人员池读不到时,工单照样被记录下来,只是无主。匿名网页转工单表单提交的工单和其他来路一样参与分配 —— 提交者只会得知工单已收到,永远看不到它分给了谁。而如果没有人持有 `service_agent`,这个钩子什么也不做,工单就一直无主,停在[工单](/zh-Hans/docs/service/cases)页面的 **未分派 — 待分诊** 页签里。那个页签正是这个钩子“什么也不做”这条路径的可见化,所以给这个岗位配上人本身就是一次行为变化:新工单不再无主到达,也不再出现在那里。**这不是升级交接**:那是另一个人员池、另一个时机 —— 它挂在转为 *Escalated* 的状态迁移上,把工单交给持有 `service_manager` 岗位的人中负载最轻的那一位(参见 [SLA 与升级](/zh-Hans/docs/service/sla-and-escalation))。 - [ ] 把 `service_agent` 岗位分配给每一位应当接收工单的客服 —— 这个人员池*就是*路由表 - [ ] 在还没有人持有它的时候,盯住工单页面的 **未分派 — 待分诊** 页签:它列出的正是这个钩子没能分配掉的工单 diff --git a/content/docs/administration/setup.zh-Hant.mdx b/content/docs/administration/setup.zh-Hant.mdx index db7d58df..855e06f9 100644 --- a/content/docs/administration/setup.zh-Hant.mdx +++ b/content/docs/administration/setup.zh-Hant.mdx @@ -115,7 +115,7 @@ description: 讓 HotCRM 為你的團隊做好生產準備的第一週任務。 ### 12. 工單路由 —— 沒有需要設定的東西 -**不存在「Setup → Case Settings」這個畫面**,也沒有任何路由規則可寫。一張無所有者的工單 —— 最常見的來路是網頁轉工單(web-to-case)提交 —— 會在插入時由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)指派:交給持有 `service_agent` 職位的人當中**未結工單最少**的那一位,這裡的「未結」指狀態既不是 *Resolved* 也不是 *Closed*。這和潛在客戶那一側是同一種自平衡輪詢:比的是誰負載最輕,而不是按次序輪換,也不是按產品、區域或來源比對的規則;沒有區域路由,也沒有佇列。它**只在插入時執行**,而且只處理到達時就沒有所有者的工單 —— 它不會在之後把工單從某個人手上拿走,也不會在客服自己新建工單時觸發,因為掛鉤跑起來之前平台已經把建立者蓋成了所有者。它也從不阻斷工單進入:當人員池根本讀不到時(匿名網頁轉工單表單就是這種情形),工單照樣被記錄下來,只是無主。而如果沒有人持有 `service_agent`,這個掛鉤什麼也不做,工單就一直無主,停在[工單](/zh-Hant/docs/service/cases)頁面的 **未分派 — 待分診** 頁籤裡。那個頁籤正是這個掛鉤「什麼也不做」這條路徑的可見化,所以給這個職位配上人本身就是一次行為變化:新工單不再無主到達,也不再出現在那裡。**這不是升級交接**:那是另一個人員池、另一個時機 —— 它掛在轉為 *Escalated* 的狀態遷移上,把工單交給持有 `service_manager` 職位的人當中負載最輕的那一位(參見 [SLA 與升級](/zh-Hant/docs/service/sla-and-escalation))。 +**不存在「Setup → Case Settings」這個畫面**,也沒有任何路由規則可寫。一張無所有者的工單 —— 最常見的來路是網頁轉工單(web-to-case)提交 —— 會在插入時由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)指派:交給持有 `service_agent` 職位的人當中**未結工單最少**的那一位,這裡的「未結」指狀態既不是 *Resolved* 也不是 *Closed*。這和潛在客戶那一側是同一種自平衡輪詢:比的是誰負載最輕,而不是按次序輪換,也不是按產品、區域或來源比對的規則;沒有區域路由,也沒有佇列。它**只在插入時執行**,而且只處理到達時就沒有所有者的工單 —— 它不會在之後把工單從某個人手上拿走,也不會在客服自己新建工單時觸發,因為掛鉤跑起來之前平台已經把建立者蓋成了所有者。它也從不阻斷工單進入:人員池讀不到時,工單照樣被記錄下來,只是無主。匿名網頁轉工單表單提交的工單和其他來路一樣參與指派 —— 提交者只會得知工單已收到,永遠看不到它指派給了誰。而如果沒有人持有 `service_agent`,這個掛鉤什麼也不做,工單就一直無主,停在[工單](/zh-Hant/docs/service/cases)頁面的 **未分派 — 待分診** 頁籤裡。那個頁籤正是這個掛鉤「什麼也不做」這條路徑的可見化,所以給這個職位配上人本身就是一次行為變化:新工單不再無主到達,也不再出現在那裡。**這不是升級交接**:那是另一個人員池、另一個時機 —— 它掛在轉為 *Escalated* 的狀態遷移上,把工單交給持有 `service_manager` 職位的人當中負載最輕的那一位(參見 [SLA 與升級](/zh-Hant/docs/service/sla-and-escalation))。 - [ ] 把 `service_agent` 職位指派給每一位應當接收工單的客服 —— 這個人員池*就是*路由表 - [ ] 在還沒有人持有它的時候,盯住工單頁面的 **未分派 — 待分診** 頁籤:它列出的正是這個掛鉤沒能指派掉的工單 diff --git a/content/docs/sales/leads.mdx b/content/docs/sales/leads.mdx index 5ab427e1..5ad42811 100644 --- a/content/docs/sales/leads.mdx +++ b/content/docs/sales/leads.mdx @@ -105,7 +105,7 @@ Work them from the **Suspected Duplicates** view in the Leads list's view switch - **It's a new enquiry from a known person** — clear Duplicate Status and work the lead normally. A returning prospect with a fresh budget is a real opportunity. - **It's the same enquiry twice** — set the lead to *Unqualified* with the reason **Duplicate**. The form then requires you to name the surviving record and set Duplicate Status to **Confirmed**; the lead cannot be closed as a duplicate without saying what survives it, so the disqualification report always links to something you can open. -> Anonymous Web-to-Lead submissions are always accepted. If the public form cannot check for existing records (it deliberately has no read access to your CRM data), the lead lands unflagged — review it from the normal lead queue. +> Anonymous Web-to-Lead submissions are always accepted, and they are checked like any other lead: a web enquiry from an address already in your CRM is flagged. The check runs on your side — the visitor is only told the enquiry was received, so the public form never reveals whether an address is already known. ### When the surviving record is later deleted diff --git a/content/docs/sales/leads.zh-Hans.mdx b/content/docs/sales/leads.zh-Hans.mdx index 6e1177b4..ead9e8a6 100644 --- a/content/docs/sales/leads.zh-Hans.mdx +++ b/content/docs/sales/leads.zh-Hans.mdx @@ -105,7 +105,7 @@ description: 在入站咨询成为真正交易之前对其进行捕获、评分 - **是老客户的新咨询**——清空重复状态,按正常线索推进。预算重新到位的老客户是真实商机。 - **是同一次咨询重复录入**——把线索置为*未通过*,原因选**重复**。此时表单会要求你指明存活的那条记录,并把重复状态设为**已确认重复**。不指明存活记录就无法以重复为由作废,所以作废分析里的每一条都能点开。 -> 匿名 Web-to-Lead 提交永远会被接受。公开表单有意不具备读取 CRM 数据的权限,如果它无法查询已有记录,线索会以未标记的状态落库——请在常规线索队列中复核。 +> 匿名 Web-to-Lead 提交永远会被接受,并且和其他线索一样会被查重:来自 CRM 中已有地址的网页咨询会被标记。查重在你这一侧完成——访客只会得知咨询已收到,因此公开表单永远不会透露某个地址是否已被记录。 ## 转化一条已确认的线索 diff --git a/content/docs/sales/leads.zh-Hant.mdx b/content/docs/sales/leads.zh-Hant.mdx index 9c37e475..edd6573f 100644 --- a/content/docs/sales/leads.zh-Hant.mdx +++ b/content/docs/sales/leads.zh-Hant.mdx @@ -107,7 +107,7 @@ description: 在入站詢問成為真正交易之前對其進行擷取、評分 - **是老客戶的新詢問**——清空重複狀態,按正常流程推進。預算重新到位的老客戶是真實商機。 - **是同一次詢問重複輸入**——把記錄設為*未通過*,原因選**重複**。此時表單會要求你指明存活的那筆記錄,並把重複狀態設為**已確認重複**。不指明存活記錄就無法以重複為由作廢,所以作廢分析裡的每一筆都能點開。 -> 匿名 Web-to-Lead 提交永遠會被接受。公開表單刻意不具備讀取 CRM 資料的權限,如果它無法查詢既有記錄,記錄會以未標記的狀態寫入——請在常規佇列中複核。 +> 匿名 Web-to-Lead 提交永遠會被接受,並且和其他潛在客戶一樣會被查重:來自 CRM 中既有地址的網頁詢問會被標記。查重在你這一側完成——訪客只會得知詢問已收到,因此公開表單永遠不會透露某個地址是否已被記錄。 ## 轉化一筆已確認的潛在客戶 From a4d68125edee9049f4048b847fd90b0498845ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:31:50 +0000 Subject: [PATCH 5/5] docs(lead): an unassigned lead is captured ownerless, routed by nothing The two comments in lead_auto_assign said a lead the round-robin could not place is routed downstream by a manager or the lead_assignment flow. No flow routes an ownerless lead (#2060 item 6), so the comments now say only that the lead is still captured, ownerless. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- src/sales/objects/lead.hook.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/sales/objects/lead.hook.ts b/src/sales/objects/lead.hook.ts index cfbf7203..bbc1ee61 100644 --- a/src/sales/objects/lead.hook.ts +++ b/src/sales/objects/lead.hook.ts @@ -105,8 +105,7 @@ const leadAutoAssignHook: Hook = { // Auto-assignment is a best-effort ENHANCEMENT — it must NEVER block lead // creation. So the pool lookup + load balancing are wrapped: on ANY error - // the lead is still captured, ownerless (a manager / the lead_assignment - // flow routes it). + // the lead is still captured, ownerless. try { // Rep pool = holders of the sales_rep position. const holders = await api.object('sys_user_position').find({ @@ -136,8 +135,8 @@ const leadAutoAssignHook: Hook = { if (best) input.owner_id = best; } catch { // Swallow: auto-assignment must never block the insert — the lead is - // still captured, ownerless, for downstream routing. (No `console` here — - // the L2 hook sandbox doesn't define it.) + // still captured, ownerless. (No `console` here — the L2 hook sandbox + // doesn't define it.) } }, };