Skip to content

Commit be796ad

Browse files
os-zhuangclaude
andcommitted
feat(crm): working Web-to-Lead public form example
The app-crm example showed Web-to-Lead only via `customer.portal` `anonymousEntry` — a spec property with NO runtime consumer, so it never worked (404). Add the mechanism that actually does, and make the example coherent: - lead.view.ts: `web_to_lead` form view with `sharing.allowAnonymous` → live `GET/POST /api/v1/forms/contact-us` (+ /submit). - sales-roles.ts: `GuestPortalProfile` (isProfile, INSERT-only on crm_lead, keyed by FULL object name — the anonymous permission path requires it). - lead.object.ts: `status` gets `defaultValue: 'new'` so a minimal public create satisfies `required` (the option-level `default` is only a UI preselect). - customer.portal.ts: drop the dead `anonymousEntry` routes; point to the working form view instead (re-add when the runtime mounts anonymousEntry). - security/index.ts + objectstack.config.ts: export + register the guest profile. Doubles as a CI regression test for the public-form path (builds against the workspace) — the #1989 flattened-metadata resolution bug would have been caught here. Verified end-to-end (app-crm, no auth): GET /forms/contact-us → 200 form spec; POST /forms/contact-us/submit → creates a crm_lead (status=new); GET /data/crm_lead → 401 (guests can't read). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 92d75ca commit be796ad

6 files changed

Lines changed: 62 additions & 13 deletions

File tree

examples/app-crm/objectstack.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
SalesManagerRole,
2424
FinanceApproverRole,
2525
SalesUserPermissionSet,
26+
GuestPortalProfile,
2627
HighValueOpportunitySharingRule,
2728
RepLeadSharingRule,
2829
WonDealActivitySharingRule,
@@ -116,7 +117,7 @@ export default defineStack({
116117

117118
// Security
118119
roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole],
119-
permissions: [SalesUserPermissionSet],
120+
permissions: [SalesUserPermissionSet, GuestPortalProfile],
120121
sharingRules: [
121122
HighValueOpportunitySharingRule,
122123
RepLeadSharingRule,

examples/app-crm/src/objects/lead.object.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export const Lead = ObjectSchema.create({
3636
status: Field.select({
3737
label: 'Status',
3838
required: true,
39+
// Write-path default so minimal creates (e.g. the public Web-to-Lead form,
40+
// which doesn't expose status) satisfy `required` — the option `default`
41+
// below is only a UI preselect.
42+
defaultValue: 'new',
3943
options: [
4044
{ label: 'New', value: 'new', default: true, color: '#94A3B8' },
4145
{ label: 'Contacted', value: 'contacted', color: '#3B82F6' },

examples/app-crm/src/portals/customer.portal.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,11 @@ export const CustomerPortal: Portal = {
5151
target: '_blank',
5252
},
5353
],
54-
anonymousEntry: {
55-
routes: [
56-
{
57-
path: '/contact',
58-
actionRef: 'crm_lead.create',
59-
rateLimit: { rule: '5/hour/ip', scope: 'ip' },
60-
captcha: true,
61-
bindIdentityFromField: 'email',
62-
},
63-
],
64-
defaultRateLimit: { rule: '100/day/ip', scope: 'ip' },
65-
},
54+
// NOTE: `anonymousEntry` is a spec property with no runtime consumer yet — the
55+
// routes here would never mount (verified: 404). For a WORKING public capture
56+
// form, see the `web_to_lead` form view in `views/lead.view.ts`
57+
// (`sharing.allowAnonymous` → live `/api/v1/forms/contact-us` endpoint). Re-add
58+
// `anonymousEntry` here only once the runtime mounts it.
6659
defaultRoute: {
6760
viewRef: 'crm_activity.activity_grid',
6861
},

examples/app-crm/src/security/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
SalesManagerRole,
66
FinanceApproverRole,
77
SalesUserPermissionSet,
8+
GuestPortalProfile,
89
} from './sales-roles.js';
910

1011
export {

examples/app-crm/src/security/sales-roles.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,21 @@ export const SalesUserPermissionSet: Security.PermissionSet = {
4343
crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4444
},
4545
};
46+
47+
/**
48+
* Guest profile for the public Web-to-Lead form (lead.view.ts `web_to_lead`).
49+
*
50+
* Applied to anonymous (unauthenticated) visitors who POST the public form. The
51+
* anonymous permission path checks the FULL object name, so this MUST key
52+
* `crm_lead` (not a short `lead`). INSERT-only — guests can never read, edit, or
53+
* delete any record.
54+
*/
55+
export const GuestPortalProfile: Security.PermissionSet = {
56+
name: 'guest_portal',
57+
label: 'Guest (Public Forms)',
58+
description: 'Anonymous Web-to-Lead submitters — INSERT-only on crm_lead.',
59+
isProfile: true,
60+
objects: {
61+
crm_lead: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false },
62+
},
63+
};

examples/app-crm/src/views/lead.view.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,38 @@ export const LeadViews = defineView({
4545
},
4646
},
4747
formViews: {
48+
/**
49+
* PUBLIC / ANONYMOUS — Web-to-Lead.
50+
*
51+
* Hosted at GET/POST `/api/v1/forms/contact-us` (+ `/submit`). An
52+
* unauthenticated visitor — or an `EmbeddableForm` iframe on a marketing
53+
* site — submits it to create a `crm_lead`. `sharing.allowAnonymous` opens
54+
* the public endpoint; the `guest_portal` profile (INSERT-only on crm_lead)
55+
* authorizes the write. The lead object's own defaults/hooks stamp internal
56+
* fields (status, owner, score).
57+
*/
58+
web_to_lead: {
59+
type: 'simple',
60+
data: { provider: 'object', object: 'crm_lead' },
61+
sections: [
62+
{
63+
label: 'Contact us',
64+
columns: 1,
65+
fields: [
66+
{ field: 'name', required: true },
67+
{ field: 'company' },
68+
{ field: 'email', required: true },
69+
{ field: 'phone' },
70+
{ field: 'title' },
71+
],
72+
},
73+
],
74+
sharing: {
75+
enabled: true,
76+
allowAnonymous: true,
77+
publicLink: '/forms/contact-us',
78+
},
79+
},
4880
default: {
4981
type: 'simple',
5082
sections: [

0 commit comments

Comments
 (0)