twenty-app-intake 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/manifest.json +38 -660
- package/package.json +13 -10
- package/public/cover.generated.png +0 -0
- package/src/front-components/dashboard.mjs +19 -21947
- package/src/front-components/dashboard.mjs.map +4 -4
- package/src/front-components/dashboard.tsx +118 -0
- package/src/front-components/settings-panel.mjs +19 -22103
- package/src/front-components/settings-panel.mjs.map +4 -4
- package/src/front-components/settings-panel.tsx +255 -0
- package/src/front-components/source-panel.mjs +20 -21962
- package/src/front-components/source-panel.mjs.map +4 -4
- package/src/front-components/source-panel.tsx +113 -0
- package/src/logic-functions/health.mjs.map +2 -2
- package/src/logic-functions/health.ts +26 -0
- package/src/logic-functions/register.mjs.map +2 -2
- package/src/logic-functions/register.ts +105 -0
- package/src/logic-functions/retry.mjs +3 -0
- package/src/logic-functions/retry.mjs.map +2 -2
- package/src/logic-functions/retry.ts +124 -0
- package/src/logic-functions/test-ingest.mjs.map +2 -2
- package/src/logic-functions/test-ingest.ts +75 -0
- package/src/logic-functions/webhook.mjs +3 -0
- package/src/logic-functions/webhook.mjs.map +2 -2
- package/src/logic-functions/webhook.ts +83 -0
- package/src/post-install.mjs.map +2 -2
- package/src/post-install.ts +81 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
const SETTINGS = [
|
|
5
|
+
{
|
|
6
|
+
key: 'INTAKE_APP_LABEL',
|
|
7
|
+
label: 'App Label',
|
|
8
|
+
defaultValue: 'Intake',
|
|
9
|
+
description:
|
|
10
|
+
'The name used in generated content — note titles, opportunity names, and system messages. Change to match your team\'s terminology, e.g. "Leads", "Inbound", "Pipeline".',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
key: 'INTAKE_DEFAULT_OPP_STAGE',
|
|
14
|
+
label: 'Default Opportunity Stage',
|
|
15
|
+
defaultValue: 'NEW',
|
|
16
|
+
description:
|
|
17
|
+
'Stage assigned to automatically created Opportunities. Must match an existing stage value in your workspace (e.g. NEW, SCREENING, MEETING, PROPOSAL, CUSTOMER).',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: 'INTAKE_FIELD_CREATION_ENABLED',
|
|
21
|
+
label: 'Auto-create Custom Fields',
|
|
22
|
+
defaultValue: 'true',
|
|
23
|
+
description:
|
|
24
|
+
'When true, Intake automatically creates custom ext fields on Person and Company records when it encounters unknown payload keys. Set to false to route all unknown fields to the note instead.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: 'INTAKE_MAX_EXT_FIELDS',
|
|
28
|
+
label: 'Max Custom Fields Per Object',
|
|
29
|
+
defaultValue: '50',
|
|
30
|
+
description:
|
|
31
|
+
'Maximum number of ext fields allowed per object. Once the limit is reached, any additional unknown fields from that object go to the overflow note instead of extending the schema.',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: 'INTAKE_DEDUP_WINDOW_MINUTES',
|
|
35
|
+
label: 'Deduplication Window (minutes)',
|
|
36
|
+
defaultValue: '5',
|
|
37
|
+
description:
|
|
38
|
+
'Payloads with identical content received within this window are treated as duplicates and skipped. Set to 0 to disable deduplication and accept every request.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: 'INTAKE_REQUIRE_HMAC',
|
|
42
|
+
label: 'Require Signed Webhooks',
|
|
43
|
+
defaultValue: 'false',
|
|
44
|
+
description:
|
|
45
|
+
'When true, every Intake Source must have a webhook secret and all requests must be signed with HMAC-SHA256. Unsigned or incorrectly signed requests are rejected with 401 regardless of source config.',
|
|
46
|
+
},
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
type SettingKey = (typeof SETTINGS)[number]['key'];
|
|
50
|
+
|
|
51
|
+
const InfoIcon: React.FC<{ description: string }> = ({ description }) => {
|
|
52
|
+
const [visible, setVisible] = useState(false);
|
|
53
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div style={{ position: 'relative', display: 'inline-block' }} ref={ref}>
|
|
57
|
+
<button
|
|
58
|
+
onMouseEnter={() => setVisible(true)}
|
|
59
|
+
onMouseLeave={() => setVisible(false)}
|
|
60
|
+
onFocus={() => setVisible(true)}
|
|
61
|
+
onBlur={() => setVisible(false)}
|
|
62
|
+
style={{
|
|
63
|
+
width: 16,
|
|
64
|
+
height: 16,
|
|
65
|
+
borderRadius: '50%',
|
|
66
|
+
border: '1.5px solid #9ca3af',
|
|
67
|
+
background: 'transparent',
|
|
68
|
+
color: '#6b7280',
|
|
69
|
+
fontSize: 10,
|
|
70
|
+
fontWeight: 700,
|
|
71
|
+
cursor: 'default',
|
|
72
|
+
display: 'inline-flex',
|
|
73
|
+
alignItems: 'center',
|
|
74
|
+
justifyContent: 'center',
|
|
75
|
+
padding: 0,
|
|
76
|
+
lineHeight: 1,
|
|
77
|
+
flexShrink: 0,
|
|
78
|
+
}}
|
|
79
|
+
aria-label="More information"
|
|
80
|
+
>
|
|
81
|
+
i
|
|
82
|
+
</button>
|
|
83
|
+
{visible && (
|
|
84
|
+
<div style={{
|
|
85
|
+
position: 'absolute',
|
|
86
|
+
bottom: 'calc(100% + 6px)',
|
|
87
|
+
left: '50%',
|
|
88
|
+
transform: 'translateX(-50%)',
|
|
89
|
+
background: '#1f2937',
|
|
90
|
+
color: '#f9fafb',
|
|
91
|
+
borderRadius: 6,
|
|
92
|
+
padding: '8px 11px',
|
|
93
|
+
fontSize: 12,
|
|
94
|
+
lineHeight: 1.5,
|
|
95
|
+
width: 260,
|
|
96
|
+
zIndex: 9999,
|
|
97
|
+
pointerEvents: 'none',
|
|
98
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
|
99
|
+
}}>
|
|
100
|
+
{description}
|
|
101
|
+
<div style={{
|
|
102
|
+
position: 'absolute',
|
|
103
|
+
top: '100%',
|
|
104
|
+
left: '50%',
|
|
105
|
+
transform: 'translateX(-50%)',
|
|
106
|
+
width: 0,
|
|
107
|
+
height: 0,
|
|
108
|
+
borderLeft: '5px solid transparent',
|
|
109
|
+
borderRight: '5px solid transparent',
|
|
110
|
+
borderTop: '5px solid #1f2937',
|
|
111
|
+
}} />
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const SettingsPanel: React.FC = () => {
|
|
119
|
+
const [values, setValues] = useState<Record<string, string>>(
|
|
120
|
+
Object.fromEntries(SETTINGS.map(s => [s.key, s.defaultValue])),
|
|
121
|
+
);
|
|
122
|
+
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const apiUrl = (window as any).__TWENTY_API_URL__ ?? '';
|
|
126
|
+
const token = (window as any).__TWENTY_API_TOKEN__ ?? '';
|
|
127
|
+
if (!apiUrl || !token) return;
|
|
128
|
+
|
|
129
|
+
fetch(`${apiUrl}/graphql`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify({ query: `{ appTokens { edges { node { name } } } currentWorkspace { id } }` }),
|
|
133
|
+
})
|
|
134
|
+
.then(r => r.json())
|
|
135
|
+
.then(d => {
|
|
136
|
+
// Try to get current values from app variables endpoint
|
|
137
|
+
return fetch(`${apiUrl}/graphql`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
query: `{ applications(filter: { name: { eq: "twenty-app-intake" } }) { edges { node { id applicationVariables } } } }`,
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
})
|
|
145
|
+
.then(r => r.json())
|
|
146
|
+
.then(d => {
|
|
147
|
+
const vars = d?.data?.applications?.edges?.[0]?.node?.applicationVariables ?? {};
|
|
148
|
+
if (typeof vars === 'object') {
|
|
149
|
+
setValues(prev => ({ ...prev, ...Object.fromEntries(
|
|
150
|
+
Object.entries(vars).filter(([k]) => prev[k] !== undefined).map(([k, v]) => [k, String(v)])
|
|
151
|
+
)}));
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
.catch(() => { /* use defaults */ });
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const save = async () => {
|
|
158
|
+
setStatus('saving');
|
|
159
|
+
const apiUrl = (window as any).__TWENTY_API_URL__ ?? '';
|
|
160
|
+
const token = (window as any).__TWENTY_API_TOKEN__ ?? '';
|
|
161
|
+
try {
|
|
162
|
+
const app = await fetch(`${apiUrl}/graphql`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
query: `{ applications(filter: { name: { eq: "twenty-app-intake" } }) { edges { node { id } } } }`,
|
|
167
|
+
}),
|
|
168
|
+
}).then(r => r.json());
|
|
169
|
+
|
|
170
|
+
const appId = app?.data?.applications?.edges?.[0]?.node?.id;
|
|
171
|
+
if (!appId) throw new Error('App not found');
|
|
172
|
+
|
|
173
|
+
await fetch(`${apiUrl}/graphql`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
query: `mutation UpdateApp($id: ID!, $data: ApplicationUpdateInput!) { updateApplication(id: $id, data: $data) { id } }`,
|
|
178
|
+
variables: { id: appId, data: { applicationVariables: values } },
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
setStatus('saved');
|
|
183
|
+
setTimeout(() => setStatus('idle'), 2500);
|
|
184
|
+
} catch {
|
|
185
|
+
setStatus('error');
|
|
186
|
+
setTimeout(() => setStatus('idle'), 3000);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div style={{ padding: '24px 28px', fontFamily: 'system-ui, sans-serif', maxWidth: 540 }}>
|
|
192
|
+
<h3 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 600 }}>Configuration</h3>
|
|
193
|
+
<p style={{ margin: '0 0 24px', fontSize: 13, color: '#6b7280' }}>
|
|
194
|
+
Set your application configuration variables. Hover the <strong>i</strong> icon for details.
|
|
195
|
+
</p>
|
|
196
|
+
|
|
197
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
198
|
+
{SETTINGS.map(s => (
|
|
199
|
+
<div key={s.key}>
|
|
200
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
|
201
|
+
<label style={{ fontSize: 12, fontWeight: 500, color: '#374151', letterSpacing: '0.03em', textTransform: 'uppercase' }}>
|
|
202
|
+
{s.label}
|
|
203
|
+
</label>
|
|
204
|
+
<InfoIcon description={s.description} />
|
|
205
|
+
</div>
|
|
206
|
+
<input
|
|
207
|
+
value={values[s.key] ?? s.defaultValue}
|
|
208
|
+
onChange={e => setValues(prev => ({ ...prev, [s.key]: e.target.value }))}
|
|
209
|
+
style={{
|
|
210
|
+
width: '100%',
|
|
211
|
+
padding: '7px 10px',
|
|
212
|
+
fontSize: 13,
|
|
213
|
+
border: '1px solid #d1d5db',
|
|
214
|
+
borderRadius: 6,
|
|
215
|
+
outline: 'none',
|
|
216
|
+
background: '#fff',
|
|
217
|
+
boxSizing: 'border-box',
|
|
218
|
+
fontFamily: 'inherit',
|
|
219
|
+
}}
|
|
220
|
+
onFocus={e => { e.target.style.borderColor = '#6366f1'; e.target.style.boxShadow = '0 0 0 3px rgba(99,102,241,0.1)'; }}
|
|
221
|
+
onBlur={e => { e.target.style.borderColor = '#d1d5db'; e.target.style.boxShadow = 'none'; }}
|
|
222
|
+
/>
|
|
223
|
+
<div style={{ marginTop: 3, fontSize: 11, color: '#9ca3af', fontFamily: 'monospace' }}>{s.key}</div>
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<button
|
|
229
|
+
onClick={save}
|
|
230
|
+
disabled={status === 'saving'}
|
|
231
|
+
style={{
|
|
232
|
+
marginTop: 24,
|
|
233
|
+
padding: '8px 20px',
|
|
234
|
+
background: status === 'saved' ? '#22c55e' : status === 'error' ? '#ef4444' : '#111827',
|
|
235
|
+
color: '#fff',
|
|
236
|
+
border: 'none',
|
|
237
|
+
borderRadius: 6,
|
|
238
|
+
fontSize: 13,
|
|
239
|
+
fontWeight: 500,
|
|
240
|
+
cursor: status === 'saving' ? 'wait' : 'pointer',
|
|
241
|
+
transition: 'background 0.2s',
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
{status === 'saving' ? 'Saving…' : status === 'saved' ? '✓ Saved' : status === 'error' ? 'Save failed' : 'Save settings'}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export default defineSettingsFrontComponent({
|
|
251
|
+
universalIdentifier: '4e4cfb3b-508c-413b-a1b2-192a51c31ed4',
|
|
252
|
+
name: 'intake-settings-panel',
|
|
253
|
+
description: 'Custom settings panel with inline documentation for all Intake configuration variables.',
|
|
254
|
+
component: SettingsPanel,
|
|
255
|
+
});
|