twenty-app-intake 0.2.1 → 0.4.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.
Files changed (52) hide show
  1. package/README.md +293 -8
  2. package/manifest.json +419 -667
  3. package/package.json +6 -9
  4. package/public/cover.generated.png +0 -0
  5. package/src/front-components/dashboard.mjs +19 -21947
  6. package/src/front-components/dashboard.mjs.map +4 -4
  7. package/src/front-components/dashboard.tsx +118 -0
  8. package/src/front-components/settings-panel.mjs +19 -22103
  9. package/src/front-components/settings-panel.mjs.map +4 -4
  10. package/src/front-components/settings-panel.tsx +304 -0
  11. package/src/front-components/source-panel.mjs +20 -21962
  12. package/src/front-components/source-panel.mjs.map +4 -4
  13. package/src/front-components/source-panel.tsx +113 -0
  14. package/src/logic-functions/check-silence.mjs +263 -0
  15. package/src/logic-functions/check-silence.mjs.map +7 -0
  16. package/src/logic-functions/check-silence.ts +52 -0
  17. package/src/logic-functions/contract.mjs +507 -0
  18. package/src/logic-functions/contract.mjs.map +7 -0
  19. package/src/logic-functions/contract.ts +172 -0
  20. package/src/logic-functions/health.mjs +125 -5
  21. package/src/logic-functions/health.mjs.map +3 -3
  22. package/src/logic-functions/health.ts +62 -0
  23. package/src/logic-functions/quarantine-discard.mjs +380 -0
  24. package/src/logic-functions/quarantine-discard.mjs.map +7 -0
  25. package/src/logic-functions/quarantine-discard.ts +65 -0
  26. package/src/logic-functions/quarantine-list.mjs +211 -0
  27. package/src/logic-functions/quarantine-list.mjs.map +7 -0
  28. package/src/logic-functions/quarantine-list.ts +93 -0
  29. package/src/logic-functions/quarantine-release.mjs +1974 -0
  30. package/src/logic-functions/quarantine-release.mjs.map +7 -0
  31. package/src/logic-functions/quarantine-release.ts +94 -0
  32. package/src/logic-functions/register.mjs +34 -1
  33. package/src/logic-functions/register.mjs.map +2 -2
  34. package/src/logic-functions/register.ts +105 -0
  35. package/src/logic-functions/replay-bulk.mjs +2040 -0
  36. package/src/logic-functions/replay-bulk.mjs.map +7 -0
  37. package/src/logic-functions/replay-bulk.ts +83 -0
  38. package/src/logic-functions/replay-log.mjs +1987 -0
  39. package/src/logic-functions/replay-log.mjs.map +7 -0
  40. package/src/logic-functions/replay-log.ts +54 -0
  41. package/src/logic-functions/retry.mjs +1004 -248
  42. package/src/logic-functions/retry.mjs.map +4 -4
  43. package/src/logic-functions/retry.ts +101 -0
  44. package/src/logic-functions/test-ingest.mjs +616 -28
  45. package/src/logic-functions/test-ingest.mjs.map +4 -4
  46. package/src/logic-functions/test-ingest.ts +179 -0
  47. package/src/logic-functions/webhook.mjs +968 -209
  48. package/src/logic-functions/webhook.mjs.map +4 -4
  49. package/src/logic-functions/webhook.ts +58 -0
  50. package/src/post-install.mjs +34 -1
  51. package/src/post-install.mjs.map +2 -2
  52. package/src/post-install.ts +81 -0
@@ -0,0 +1,179 @@
1
+ import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
2
+ import { CoreApiClient } from 'twenty-client-sdk/core';
3
+ import { detectStructure, flatten } from '../lib/flattener';
4
+ import { normalizeFields, partition, type DatabaseRule } from '../lib/normalizer';
5
+ import { computePayloadHash } from '../lib/idempotency';
6
+ import { formatNote } from '../lib/note-formatter';
7
+ import { coreApi } from '../lib/rest-client';
8
+ import { buildDiff } from '../lib/diff';
9
+ import { scoreSpam, describeVerdict } from '../lib/spam';
10
+ import { honeypotField, mergePolicy, spamFilterEnabled, spamScoreThreshold } from '../lib/settings';
11
+
12
+ /**
13
+ * Dry run: what would happen, without it happening.
14
+ *
15
+ * Two things changed in 0.4.0 and both were correctness, not presentation. The
16
+ * preview now loads the same field rules the live pipeline loads — it previously
17
+ * ran with none, so any payload relying on a rule was previewed wrongly. And the
18
+ * result is a structured diff rather than a list of names, so it can be asserted
19
+ * against in a test or read by a deployment check instead of eyeballed.
20
+ */
21
+ const handler = async (params: RoutePayload) => {
22
+ const sourceSlug = params.pathParameters?.['slug'];
23
+ if (!sourceSlug) return { statusCode: 400, body: { error: 'Missing source slug' } };
24
+
25
+ let rawPayload: Record<string, unknown> = {};
26
+ if (params.body && typeof params.body === 'object' && !Array.isArray(params.body)) {
27
+ rawPayload = params.body as Record<string, unknown>;
28
+ }
29
+
30
+ const source = await loadSource(sourceSlug);
31
+ const rules = await loadRules(source?.id);
32
+
33
+ const structure = detectStructure(rawPayload);
34
+ const flat = structure.type === 'flat'
35
+ ? structure.data
36
+ : flatten(structure.extra ?? {});
37
+
38
+ const normalized = normalizeFields(flat, rules);
39
+ const { crmFields, noteFields, skipped } = partition(normalized);
40
+
41
+ const honeypot = source?.honeypotField || honeypotField();
42
+ const verdict = scoreSpam(flat, honeypot);
43
+ const wouldQuarantine = verdict.honeypot
44
+ || (spamFilterEnabled() && verdict.score >= spamScoreThreshold());
45
+
46
+ const warnings: string[] = [];
47
+ if (!source) warnings.push(`No source registered with slug "${sourceSlug}" — previewing with default settings. A live request would return 404.`);
48
+ if (source?.status === 'PAUSED') warnings.push('This source is PAUSED — a live request would return 423.');
49
+
50
+ const noteOverflow: Record<string, unknown> = {};
51
+ for (const f of noteFields) noteOverflow[f.canonicalName] = f.value;
52
+
53
+ // A payload that would be held never reaches the record stages, so describing
54
+ // what it would write would be describing something that will not happen.
55
+ const diff = wouldQuarantine ? null : await buildDiff({
56
+ crmFields,
57
+ noteFields,
58
+ skipped,
59
+ structuredCompany: structure.type === 'structured' ? (structure.company ?? undefined) : undefined,
60
+ structuredPerson: structure.type === 'structured' ? (structure.person ?? undefined) : undefined,
61
+ createOpportunity: source?.createOpportunity ?? true,
62
+ targetObject: (source?.targetObject as 'PERSON' | 'COMPANY' | 'AUTO') ?? 'AUTO',
63
+ policy: parsePolicy(source?.mergePolicy) ?? mergePolicy(),
64
+ warnings,
65
+ });
66
+
67
+ return {
68
+ statusCode: 200,
69
+ body: {
70
+ dryRun: true,
71
+ source: source ? { id: source.id, name: source.name, slug: source.slug, status: source.status } : null,
72
+ payloadHash: computePayloadHash(rawPayload),
73
+ payloadStructure: structure.type,
74
+
75
+ spam: {
76
+ score: verdict.score,
77
+ threshold: spamScoreThreshold(),
78
+ filterEnabled: spamFilterEnabled(),
79
+ honeypotField: honeypot || null,
80
+ wouldQuarantine,
81
+ signals: verdict.signals,
82
+ reason: verdict.signals.length > 0 ? describeVerdict(verdict) : null,
83
+ },
84
+
85
+ mergePolicy: parsePolicy(source?.mergePolicy) ?? mergePolicy(),
86
+
87
+ /**
88
+ * Per object: whether it would be created or updated, which record it
89
+ * matched, what each field would do to the value already there, and which
90
+ * fields do not exist yet and would be added to the schema.
91
+ */
92
+ diff,
93
+
94
+ note: noteFields.length > 0
95
+ ? { fields: noteFields.map((f) => f.canonicalName), preview: formatNote(source?.name ?? sourceSlug, noteOverflow) }
96
+ : null,
97
+
98
+ rulesApplied: rules.length,
99
+ warnings,
100
+ rawFlat: flat,
101
+ },
102
+ };
103
+ };
104
+
105
+ type SourceNode = {
106
+ id: string;
107
+ name: string;
108
+ slug: string;
109
+ status: string;
110
+ targetObject: string;
111
+ createOpportunity: boolean | null;
112
+ honeypotField?: string | null;
113
+ mergePolicy?: string | null;
114
+ };
115
+
116
+ async function loadSource(slug: string): Promise<SourceNode | null> {
117
+ try {
118
+ const res = await coreApi.get<{ data: { intakeSources: { edges: Array<{ node: SourceNode }> } } }>(
119
+ `/intakeSources?filter=slug[eq]:${encodeURIComponent(slug)}&first=1`,
120
+ );
121
+ return res.data?.intakeSources?.edges?.[0]?.node ?? null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /** The same query NormalizeStage runs, so the preview sees the same rules. */
128
+ async function loadRules(sourceId: string | undefined): Promise<DatabaseRule[]> {
129
+ try {
130
+ const client = new CoreApiClient();
131
+ const filter = sourceId
132
+ ? { or: [{ intakeSource: { id: { eq: sourceId } } }, { intakeSource: { id: { is: 'NULL' } } }] }
133
+ : { intakeSource: { id: { is: 'NULL' } } };
134
+
135
+ const result = await client.query({
136
+ intakeFieldRules: {
137
+ __args: {
138
+ filter: { and: [filter, { isActive: { eq: true } }] },
139
+ orderBy: { priority: 'DescNullsLast' },
140
+ },
141
+ edges: {
142
+ node: {
143
+ inputPattern: true,
144
+ canonicalName: true,
145
+ fieldType: true,
146
+ priority: true,
147
+ targetObject: true,
148
+ mergeStrategy: true,
149
+ },
150
+ },
151
+ },
152
+ });
153
+
154
+ return (result.intakeFieldRules?.edges ?? []).map((e: { node: DatabaseRule }) => e.node);
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+
160
+ function parsePolicy(raw: string | null | undefined): 'PRESERVE' | 'NEWEST_WINS' | null {
161
+ switch ((raw ?? '').toUpperCase()) {
162
+ case 'PRESERVE': return 'PRESERVE';
163
+ case 'NEWEST_WINS': return 'NEWEST_WINS';
164
+ default: return null;
165
+ }
166
+ }
167
+
168
+ export default defineLogicFunction({
169
+ universalIdentifier: '3f7a9c21-8d4b-4e6f-a1c2-5b8d0f2e7a4c',
170
+ name: 'intake-test',
171
+ description: 'Dry-run: returns a structured diff of what would be created or updated, writing nothing.',
172
+ timeoutSeconds: 20,
173
+ handler,
174
+ httpRouteTriggerSettings: {
175
+ path: '/intake/:slug/test',
176
+ httpMethod: 'POST',
177
+ isAuthRequired: false,
178
+ },
179
+ });