@kelpie/schemas 0.8.0 → 0.9.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 (91) hide show
  1. package/dist/company.d.ts +8 -0
  2. package/dist/company.d.ts.map +1 -1
  3. package/dist/company.js +4 -0
  4. package/dist/company.js.map +1 -1
  5. package/dist/consentPurpose.d.ts +49 -0
  6. package/dist/consentPurpose.d.ts.map +1 -0
  7. package/dist/consentPurpose.js +40 -0
  8. package/dist/consentPurpose.js.map +1 -0
  9. package/dist/customField.d.ts +99 -0
  10. package/dist/customField.d.ts.map +1 -0
  11. package/dist/customField.js +133 -0
  12. package/dist/customField.js.map +1 -0
  13. package/dist/deal.d.ts +8 -0
  14. package/dist/deal.d.ts.map +1 -1
  15. package/dist/deal.js +4 -0
  16. package/dist/deal.js.map +1 -1
  17. package/dist/enquiry.d.ts +43 -0
  18. package/dist/enquiry.d.ts.map +1 -0
  19. package/dist/enquiry.js +47 -0
  20. package/dist/enquiry.js.map +1 -0
  21. package/dist/form.d.ts +117 -2
  22. package/dist/form.d.ts.map +1 -1
  23. package/dist/form.js +102 -1
  24. package/dist/form.js.map +1 -1
  25. package/dist/formSubmission.d.ts +29 -0
  26. package/dist/formSubmission.d.ts.map +1 -1
  27. package/dist/formSubmission.js +34 -0
  28. package/dist/formSubmission.js.map +1 -1
  29. package/dist/importExport.d.ts +28 -4
  30. package/dist/importExport.d.ts.map +1 -1
  31. package/dist/importExport.js +24 -1
  32. package/dist/importExport.js.map +1 -1
  33. package/dist/index.d.ts +15 -5
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/list.d.ts +10 -0
  38. package/dist/list.d.ts.map +1 -1
  39. package/dist/list.js +6 -0
  40. package/dist/list.js.map +1 -1
  41. package/dist/names.d.ts +43 -0
  42. package/dist/names.d.ts.map +1 -0
  43. package/dist/names.js +35 -0
  44. package/dist/names.js.map +1 -0
  45. package/dist/opportunity.d.ts +10 -0
  46. package/dist/opportunity.d.ts.map +1 -1
  47. package/dist/opportunity.js +7 -0
  48. package/dist/opportunity.js.map +1 -1
  49. package/dist/partnership.d.ts +8 -0
  50. package/dist/partnership.d.ts.map +1 -1
  51. package/dist/partnership.js +4 -0
  52. package/dist/partnership.js.map +1 -1
  53. package/dist/person.d.ts +42 -1
  54. package/dist/person.d.ts.map +1 -1
  55. package/dist/person.js +23 -0
  56. package/dist/person.js.map +1 -1
  57. package/dist/personConsent.d.ts +31 -0
  58. package/dist/personConsent.d.ts.map +1 -0
  59. package/dist/personConsent.js +27 -0
  60. package/dist/personConsent.js.map +1 -0
  61. package/dist/raise.d.ts +8 -0
  62. package/dist/raise.d.ts.map +1 -1
  63. package/dist/raise.js +4 -0
  64. package/dist/raise.js.map +1 -1
  65. package/dist/sampleData.d.ts +1 -0
  66. package/dist/sampleData.d.ts.map +1 -1
  67. package/dist/sampleData.js +2 -0
  68. package/dist/sampleData.js.map +1 -1
  69. package/dist/values.d.ts +86 -8
  70. package/dist/values.d.ts.map +1 -1
  71. package/dist/values.js +140 -3
  72. package/dist/values.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/company.ts +13 -0
  75. package/src/consentPurpose.ts +95 -0
  76. package/src/customField.ts +211 -0
  77. package/src/deal.ts +13 -0
  78. package/src/enquiry.ts +94 -0
  79. package/src/form.ts +230 -2
  80. package/src/formSubmission.ts +63 -0
  81. package/src/importExport.ts +44 -5
  82. package/src/index.ts +70 -2
  83. package/src/list.ts +16 -0
  84. package/src/names.ts +49 -0
  85. package/src/opportunity.ts +18 -0
  86. package/src/partnership.ts +13 -0
  87. package/src/person.ts +69 -1
  88. package/src/personConsent.ts +60 -0
  89. package/src/raise.ts +13 -0
  90. package/src/sampleData.ts +3 -0
  91. package/src/values.ts +169 -3
package/src/enquiry.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { z } from 'zod'
2
+
3
+ import { customFieldValuesBody, customFieldValuesSchema } from './customField.ts'
4
+ import type { CustomFieldValue, CustomFieldValues } from './customField.ts'
5
+ import { definedFields, idSchema, recordTimestamps } from './wire.ts'
6
+ import type { RecordTimestamps } from './wire.ts'
7
+
8
+ /** Wire and write shapes for `/v1/enquiries`. */
9
+
10
+ export interface Enquiry extends RecordTimestamps {
11
+ readonly id: string
12
+ readonly name: string
13
+ /** Free text ("Website", "Email", "Referral", …), like Opportunity's `kind`. */
14
+ readonly source: string
15
+ readonly stageId: string
16
+ /** Nullable: an early enquiry may arrive before a company is on file. */
17
+ readonly companyId: string | null
18
+ readonly ownerId: string | null
19
+ /** Non-null once the enquiry has been converted to a Deal; nulled if that deal is deleted. */
20
+ readonly convertedDealId: string | null
21
+ readonly personIds: readonly string[]
22
+ readonly summary: string
23
+ readonly tags: readonly string[]
24
+ /** Workspace-defined fields, keyed by definition key. Always present (default `{}`). */
25
+ readonly customFields: CustomFieldValues
26
+ }
27
+
28
+ export const enquirySchema: z.ZodType<Enquiry, unknown> = z
29
+ .object({
30
+ id: idSchema,
31
+ name: z.string(),
32
+ source: z.string(),
33
+ stage_id: idSchema,
34
+ company_id: idSchema.nullable(),
35
+ owner_id: idSchema.nullable(),
36
+ converted_deal_id: idSchema.nullable(),
37
+ person_ids: z.array(idSchema),
38
+ summary: z.string(),
39
+ tags: z.array(z.string()),
40
+ custom_fields: customFieldValuesSchema,
41
+ ...recordTimestamps,
42
+ })
43
+ .transform(
44
+ (wire): Enquiry => ({
45
+ id: wire.id,
46
+ name: wire.name,
47
+ source: wire.source,
48
+ stageId: wire.stage_id,
49
+ companyId: wire.company_id,
50
+ ownerId: wire.owner_id,
51
+ convertedDealId: wire.converted_deal_id,
52
+ personIds: wire.person_ids,
53
+ summary: wire.summary,
54
+ tags: wire.tags,
55
+ customFields: wire.custom_fields,
56
+ createdAt: wire.created_at,
57
+ updatedAt: wire.updated_at,
58
+ }),
59
+ )
60
+
61
+ /**
62
+ * `convertedDealId` is deliberately absent from the input: it is set only by
63
+ * `POST /v1/enquiries/:id/convert`, and a `PATCH` that carried it is `422`.
64
+ */
65
+ export interface EnquiryInput {
66
+ readonly name?: string
67
+ readonly source?: string
68
+ readonly stageId?: string
69
+ readonly companyId?: string | null
70
+ readonly ownerId?: string | null
71
+ readonly personIds?: readonly string[]
72
+ readonly summary?: string
73
+ readonly tags?: readonly string[]
74
+ /**
75
+ * Partial merge patch: sent keys change, `null` clears a key, absent keys are
76
+ * left alone. Unknown keys are rejected at `422`.
77
+ */
78
+ readonly customFields?: Readonly<Record<string, CustomFieldValue | null>>
79
+ }
80
+
81
+ export function enquiryBody(input: EnquiryInput): Record<string, unknown> {
82
+ return definedFields({
83
+ name: input.name,
84
+ source: input.source,
85
+ stage_id: input.stageId,
86
+ company_id: input.companyId,
87
+ owner_id: input.ownerId,
88
+ person_ids: input.personIds,
89
+ summary: input.summary,
90
+ tags: input.tags,
91
+ custom_fields:
92
+ input.customFields === undefined ? undefined : customFieldValuesBody(input.customFields),
93
+ })
94
+ }
package/src/form.ts CHANGED
@@ -5,8 +5,15 @@ import {
5
5
  FORM_FIELD_TYPES,
6
6
  FORM_OPTION_VALUE_TYPES,
7
7
  FORM_STATUSES,
8
+ PIPELINE_KINDS,
9
+ } from './values.ts'
10
+ import type {
11
+ FormFieldMapTarget,
12
+ FormFieldType,
13
+ FormOptionValueType,
14
+ FormStatus,
15
+ PipelineKind,
8
16
  } from './values.ts'
9
- import type { FormFieldMapTarget, FormFieldType, FormOptionValueType, FormStatus } from './values.ts'
10
17
  import { definedFields, idSchema, recordTimestamps } from './wire.ts'
11
18
  import type { RecordTimestamps } from './wire.ts'
12
19
 
@@ -40,13 +47,46 @@ export interface FormField {
40
47
  /** Empty for every type but `select`. */
41
48
  readonly options: readonly FormFieldOption[]
42
49
  readonly placeholder: string | null
50
+ /**
51
+ * The intro sentence for a `consent` field — sits above the list of purpose
52
+ * checkboxes. `label` is the field heading; `statement` is what the visitor
53
+ * reads before ticking. Null for every other type.
54
+ */
55
+ readonly statement: string | null
56
+ /**
57
+ * Workspace consent purposes this `consent` field offers, in display order.
58
+ * Non-empty when `type === 'consent'`; empty otherwise. Each ticked box
59
+ * grants the purpose it belongs to; an unticked one is absence.
60
+ */
61
+ readonly consentPurposeIds: readonly string[]
62
+ /**
63
+ * Per-purpose override for the checkbox text, keyed by purpose id. Absent
64
+ * keys fall back to the workspace purpose's own label. Only the wording
65
+ * changes — the checkbox stays bound to its purpose.
66
+ */
67
+ readonly consentPurposeLabels: Readonly<Record<string, string>>
43
68
  /** Position in the form, contiguous from 0. The server renumbers on every write. */
44
69
  readonly sortOrder: number
45
70
  }
46
71
 
72
+ /**
73
+ * One pre-existing pipeline record every submitter is linked into through
74
+ * `person_links`. `target_type` is fixed at form-write time; the row itself
75
+ * lives in `form_attach_targets` on the server.
76
+ */
77
+ export interface FormAttachTarget {
78
+ readonly targetType: PipelineKind
79
+ readonly targetId: string
80
+ }
81
+
47
82
  export interface Form extends RecordTimestamps {
48
83
  readonly id: string
49
84
  readonly name: string
85
+ /**
86
+ * Heading on the hosted/embed page. Defaults to `name` when the form is
87
+ * created; edit it from Settings without renaming the form in the CRM.
88
+ */
89
+ readonly title: string
50
90
  readonly description: string | null
51
91
  readonly status: FormStatus
52
92
  readonly fields: readonly FormField[]
@@ -56,6 +96,39 @@ export interface Form extends RecordTimestamps {
56
96
  readonly dealStageId: string | null
57
97
  /** Expands `{{company.name}}` and `{{person.name}}`. Null when `createDeal` is off. */
58
98
  readonly dealNameTemplate: string | null
99
+ readonly createOpportunity: boolean
100
+ /** Required when `createOpportunity` is on: opportunities carry a `kind`. */
101
+ readonly opportunityKind: string | null
102
+ readonly opportunityStageId: string | null
103
+ readonly opportunityNameTemplate: string | null
104
+ /** Null falls back to the workspace default member at submit time. */
105
+ readonly opportunityOwnerId: string | null
106
+ readonly createPartnership: boolean
107
+ /** Required when `createPartnership` is on. */
108
+ readonly partnershipKind: string | null
109
+ readonly partnershipStageId: string | null
110
+ readonly partnershipNameTemplate: string | null
111
+ /** Null falls back to the workspace default member at submit time. */
112
+ readonly partnershipOwnerId: string | null
113
+ readonly createEnquiry: boolean
114
+ /**
115
+ * Free-text label for the enquiry's `source` column. Optional: an unset value
116
+ * stores an empty string. Enquiry needs no `kind` because it has no `kind`
117
+ * column — a form's source is the source.
118
+ */
119
+ readonly enquirySource: string | null
120
+ readonly enquiryStageId: string | null
121
+ readonly enquiryNameTemplate: string | null
122
+ /** Null falls back to the workspace default member at submit time. */
123
+ readonly enquiryOwnerId: string | null
124
+ /** Tags to merge (union) into the resolved Person's `tags` on every submit. */
125
+ readonly personTags: readonly string[]
126
+ /** Tags to merge (union) into the resolved Company's `tags`; skipped when no company resolved. */
127
+ readonly companyTags: readonly string[]
128
+ /** Lists (person or company) every matching record is added to on submit. */
129
+ readonly listIds: readonly string[]
130
+ /** Pre-existing pipeline records the submitter is linked to via `person_links`. */
131
+ readonly attachTargets: readonly FormAttachTarget[]
59
132
  /**
60
133
  * The handle the public submit and embed endpoints resolve a workspace from.
61
134
  * Globally unique, and the only credential those endpoints take.
@@ -86,6 +159,9 @@ const formFieldSchema = z
86
159
  map_to: z.enum(FORM_FIELD_MAP_TARGETS),
87
160
  options: z.array(formFieldOptionSchema),
88
161
  placeholder: z.string().nullable(),
162
+ statement: z.string().nullable(),
163
+ consent_purpose_ids: z.array(idSchema),
164
+ consent_purpose_labels: z.record(z.string(), z.string()),
89
165
  sort_order: z.number().int(),
90
166
  })
91
167
  .transform(
@@ -97,14 +173,30 @@ const formFieldSchema = z
97
173
  mapTo: wire.map_to,
98
174
  options: wire.options,
99
175
  placeholder: wire.placeholder,
176
+ statement: wire.statement,
177
+ consentPurposeIds: wire.consent_purpose_ids,
178
+ consentPurposeLabels: wire.consent_purpose_labels,
100
179
  sortOrder: wire.sort_order,
101
180
  }),
102
181
  )
103
182
 
183
+ const attachTargetSchema = z
184
+ .object({
185
+ target_type: z.enum(PIPELINE_KINDS),
186
+ target_id: idSchema,
187
+ })
188
+ .transform(
189
+ (wire): FormAttachTarget => ({
190
+ targetType: wire.target_type,
191
+ targetId: wire.target_id,
192
+ }),
193
+ )
194
+
104
195
  export const formSchema: z.ZodType<Form, unknown> = z
105
196
  .object({
106
197
  id: idSchema,
107
198
  name: z.string(),
199
+ title: z.string(),
108
200
  description: z.string().nullable(),
109
201
  status: z.enum(FORM_STATUSES),
110
202
  fields: z.array(formFieldSchema),
@@ -112,6 +204,25 @@ export const formSchema: z.ZodType<Form, unknown> = z
112
204
  create_deal: z.boolean(),
113
205
  deal_stage_id: idSchema.nullable(),
114
206
  deal_name_template: z.string().nullable(),
207
+ create_opportunity: z.boolean(),
208
+ opportunity_kind: z.string().nullable(),
209
+ opportunity_stage_id: idSchema.nullable(),
210
+ opportunity_name_template: z.string().nullable(),
211
+ opportunity_owner_id: idSchema.nullable(),
212
+ create_partnership: z.boolean(),
213
+ partnership_kind: z.string().nullable(),
214
+ partnership_stage_id: idSchema.nullable(),
215
+ partnership_name_template: z.string().nullable(),
216
+ partnership_owner_id: idSchema.nullable(),
217
+ create_enquiry: z.boolean(),
218
+ enquiry_source: z.string().nullable(),
219
+ enquiry_stage_id: idSchema.nullable(),
220
+ enquiry_name_template: z.string().nullable(),
221
+ enquiry_owner_id: idSchema.nullable(),
222
+ person_tags: z.array(z.string()),
223
+ company_tags: z.array(z.string()),
224
+ list_ids: z.array(idSchema),
225
+ attach_targets: z.array(attachTargetSchema),
115
226
  public_key: z.string(),
116
227
  ...recordTimestamps,
117
228
  })
@@ -119,6 +230,7 @@ export const formSchema: z.ZodType<Form, unknown> = z
119
230
  (wire): Form => ({
120
231
  id: wire.id,
121
232
  name: wire.name,
233
+ title: wire.title,
122
234
  description: wire.description,
123
235
  status: wire.status,
124
236
  fields: wire.fields,
@@ -126,6 +238,25 @@ export const formSchema: z.ZodType<Form, unknown> = z
126
238
  createDeal: wire.create_deal,
127
239
  dealStageId: wire.deal_stage_id,
128
240
  dealNameTemplate: wire.deal_name_template,
241
+ createOpportunity: wire.create_opportunity,
242
+ opportunityKind: wire.opportunity_kind,
243
+ opportunityStageId: wire.opportunity_stage_id,
244
+ opportunityNameTemplate: wire.opportunity_name_template,
245
+ opportunityOwnerId: wire.opportunity_owner_id,
246
+ createPartnership: wire.create_partnership,
247
+ partnershipKind: wire.partnership_kind,
248
+ partnershipStageId: wire.partnership_stage_id,
249
+ partnershipNameTemplate: wire.partnership_name_template,
250
+ partnershipOwnerId: wire.partnership_owner_id,
251
+ createEnquiry: wire.create_enquiry,
252
+ enquirySource: wire.enquiry_source,
253
+ enquiryStageId: wire.enquiry_stage_id,
254
+ enquiryNameTemplate: wire.enquiry_name_template,
255
+ enquiryOwnerId: wire.enquiry_owner_id,
256
+ personTags: wire.person_tags,
257
+ companyTags: wire.company_tags,
258
+ listIds: wire.list_ids,
259
+ attachTargets: wire.attach_targets,
129
260
  publicKey: wire.public_key,
130
261
  createdAt: wire.created_at,
131
262
  updatedAt: wire.updated_at,
@@ -152,10 +283,18 @@ export interface FormFieldInput {
152
283
  readonly mapTo: FormFieldMapTarget
153
284
  readonly options?: readonly FormFieldOptionInput[]
154
285
  readonly placeholder?: string | null
286
+ /** The intro sentence above the list of consent checkboxes. */
287
+ readonly statement?: string | null
288
+ /** Non-empty for `consent` fields; each id becomes a checkbox in display order. */
289
+ readonly consentPurposeIds?: readonly string[]
290
+ /** Per-purpose override for the checkbox text. Falls back to the workspace label. */
291
+ readonly consentPurposeLabels?: Readonly<Record<string, string>>
155
292
  }
156
293
 
157
294
  export interface CreateFormInput {
158
295
  readonly name: string
296
+ /** Defaults to `name` when omitted. */
297
+ readonly title?: string
159
298
  readonly description?: string | null
160
299
  readonly status?: FormStatus
161
300
  readonly fields: readonly FormFieldInput[]
@@ -163,11 +302,34 @@ export interface CreateFormInput {
163
302
  readonly createDeal?: boolean
164
303
  readonly dealStageId?: string | null
165
304
  readonly dealNameTemplate?: string | null
305
+ readonly createOpportunity?: boolean
306
+ readonly opportunityKind?: string | null
307
+ readonly opportunityStageId?: string | null
308
+ readonly opportunityNameTemplate?: string | null
309
+ readonly opportunityOwnerId?: string | null
310
+ readonly createPartnership?: boolean
311
+ readonly partnershipKind?: string | null
312
+ readonly partnershipStageId?: string | null
313
+ readonly partnershipNameTemplate?: string | null
314
+ readonly partnershipOwnerId?: string | null
315
+ readonly createEnquiry?: boolean
316
+ readonly enquirySource?: string | null
317
+ readonly enquiryStageId?: string | null
318
+ readonly enquiryNameTemplate?: string | null
319
+ readonly enquiryOwnerId?: string | null
320
+ readonly personTags?: readonly string[]
321
+ readonly companyTags?: readonly string[]
322
+ readonly listIds?: readonly string[]
323
+ readonly attachTargets?: readonly FormAttachTarget[]
166
324
  }
167
325
 
168
- /** `fields` is absent or the whole list; there is no per-field patch. */
326
+ /**
327
+ * `fields`, `list_ids`, and `attach_targets` are each absent or the whole list;
328
+ * there is no per-entry patch.
329
+ */
169
330
  export interface FormInput {
170
331
  readonly name?: string
332
+ readonly title?: string
171
333
  readonly description?: string | null
172
334
  readonly status?: FormStatus
173
335
  readonly fields?: readonly FormFieldInput[]
@@ -175,6 +337,25 @@ export interface FormInput {
175
337
  readonly createDeal?: boolean
176
338
  readonly dealStageId?: string | null
177
339
  readonly dealNameTemplate?: string | null
340
+ readonly createOpportunity?: boolean
341
+ readonly opportunityKind?: string | null
342
+ readonly opportunityStageId?: string | null
343
+ readonly opportunityNameTemplate?: string | null
344
+ readonly opportunityOwnerId?: string | null
345
+ readonly createPartnership?: boolean
346
+ readonly partnershipKind?: string | null
347
+ readonly partnershipStageId?: string | null
348
+ readonly partnershipNameTemplate?: string | null
349
+ readonly partnershipOwnerId?: string | null
350
+ readonly createEnquiry?: boolean
351
+ readonly enquirySource?: string | null
352
+ readonly enquiryStageId?: string | null
353
+ readonly enquiryNameTemplate?: string | null
354
+ readonly enquiryOwnerId?: string | null
355
+ readonly personTags?: readonly string[]
356
+ readonly companyTags?: readonly string[]
357
+ readonly listIds?: readonly string[]
358
+ readonly attachTargets?: readonly FormAttachTarget[]
178
359
  }
179
360
 
180
361
  function fieldBody(field: FormFieldInput): Record<string, unknown> {
@@ -187,12 +368,20 @@ function fieldBody(field: FormFieldInput): Record<string, unknown> {
187
368
  definedFields({ key: option.key, value: option.value, value_type: option.valueType }),
188
369
  ),
189
370
  placeholder: field.placeholder,
371
+ statement: field.statement,
372
+ consent_purpose_ids: field.consentPurposeIds,
373
+ consent_purpose_labels: field.consentPurposeLabels,
190
374
  })
191
375
  }
192
376
 
377
+ function attachTargetBody(target: FormAttachTarget): Record<string, unknown> {
378
+ return { target_type: target.targetType, target_id: target.targetId }
379
+ }
380
+
193
381
  export function createFormBody(input: CreateFormInput): Record<string, unknown> {
194
382
  return definedFields({
195
383
  name: input.name,
384
+ title: input.title,
196
385
  description: input.description,
197
386
  status: input.status,
198
387
  fields: input.fields.map(fieldBody),
@@ -200,12 +389,32 @@ export function createFormBody(input: CreateFormInput): Record<string, unknown>
200
389
  create_deal: input.createDeal,
201
390
  deal_stage_id: input.dealStageId,
202
391
  deal_name_template: input.dealNameTemplate,
392
+ create_opportunity: input.createOpportunity,
393
+ opportunity_kind: input.opportunityKind,
394
+ opportunity_stage_id: input.opportunityStageId,
395
+ opportunity_name_template: input.opportunityNameTemplate,
396
+ opportunity_owner_id: input.opportunityOwnerId,
397
+ create_partnership: input.createPartnership,
398
+ partnership_kind: input.partnershipKind,
399
+ partnership_stage_id: input.partnershipStageId,
400
+ partnership_name_template: input.partnershipNameTemplate,
401
+ partnership_owner_id: input.partnershipOwnerId,
402
+ create_enquiry: input.createEnquiry,
403
+ enquiry_source: input.enquirySource,
404
+ enquiry_stage_id: input.enquiryStageId,
405
+ enquiry_name_template: input.enquiryNameTemplate,
406
+ enquiry_owner_id: input.enquiryOwnerId,
407
+ person_tags: input.personTags,
408
+ company_tags: input.companyTags,
409
+ list_ids: input.listIds,
410
+ attach_targets: input.attachTargets?.map(attachTargetBody),
203
411
  })
204
412
  }
205
413
 
206
414
  export function formBody(input: FormInput): Record<string, unknown> {
207
415
  return definedFields({
208
416
  name: input.name,
417
+ title: input.title,
209
418
  description: input.description,
210
419
  status: input.status,
211
420
  fields: input.fields?.map(fieldBody),
@@ -213,5 +422,24 @@ export function formBody(input: FormInput): Record<string, unknown> {
213
422
  create_deal: input.createDeal,
214
423
  deal_stage_id: input.dealStageId,
215
424
  deal_name_template: input.dealNameTemplate,
425
+ create_opportunity: input.createOpportunity,
426
+ opportunity_kind: input.opportunityKind,
427
+ opportunity_stage_id: input.opportunityStageId,
428
+ opportunity_name_template: input.opportunityNameTemplate,
429
+ opportunity_owner_id: input.opportunityOwnerId,
430
+ create_partnership: input.createPartnership,
431
+ partnership_kind: input.partnershipKind,
432
+ partnership_stage_id: input.partnershipStageId,
433
+ partnership_name_template: input.partnershipNameTemplate,
434
+ partnership_owner_id: input.partnershipOwnerId,
435
+ create_enquiry: input.createEnquiry,
436
+ enquiry_source: input.enquirySource,
437
+ enquiry_stage_id: input.enquiryStageId,
438
+ enquiry_name_template: input.enquiryNameTemplate,
439
+ enquiry_owner_id: input.enquiryOwnerId,
440
+ person_tags: input.personTags,
441
+ company_tags: input.companyTags,
442
+ list_ids: input.listIds,
443
+ attach_targets: input.attachTargets?.map(attachTargetBody),
216
444
  })
217
445
  }
@@ -1,7 +1,26 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { FORM_ACTION_STATUSES } from './values.ts'
4
+ import type { FormActionStatus } from './values.ts'
3
5
  import { idSchema, timestampSchema } from './wire.ts'
4
6
 
7
+ /**
8
+ * The record types a submission can name through its FK columns. Used by
9
+ * `GET /v1/form-submissions?target_type=…` so the query, the server, and the
10
+ * UI hook agree on the seven values.
11
+ */
12
+ export const FORM_SUBMISSION_LINK_TARGETS = [
13
+ 'person',
14
+ 'company',
15
+ 'position',
16
+ 'deal',
17
+ 'opportunity',
18
+ 'partnership',
19
+ 'enquiry',
20
+ ] as const
21
+
22
+ export type FormSubmissionLinkTarget = (typeof FORM_SUBMISSION_LINK_TARGETS)[number]
23
+
5
24
  /**
6
25
  * Wire shape for `/v1/forms/:id/submissions`, and for what the public submit
7
26
  * endpoint answers with.
@@ -11,6 +30,19 @@ import { idSchema, timestampSchema } from './wire.ts'
11
30
  * submission) and no update at all.
12
31
  */
13
32
 
33
+ /**
34
+ * One post-submit action's outcome. The submission's `actionLog` carries these
35
+ * in the order the runner attempted them, so a reader can see what ran, what
36
+ * was skipped, and what rolled back.
37
+ */
38
+ export interface FormSubmissionActionEntry {
39
+ /** A machine name for the action ('create_deal', 'tag_person', 'add_list'…). */
40
+ readonly action: string
41
+ readonly status: FormActionStatus
42
+ /** Human-readable message. Never a stack trace; safe for the Submissions UI. */
43
+ readonly detail: string
44
+ }
45
+
14
46
  export interface FormSubmission {
15
47
  readonly id: string
16
48
  readonly formId: string
@@ -26,9 +58,32 @@ export interface FormSubmission {
26
58
  readonly companyId: string | null
27
59
  readonly positionId: string | null
28
60
  readonly dealId: string | null
61
+ readonly opportunityId: string | null
62
+ readonly partnershipId: string | null
63
+ readonly enquiryId: string | null
64
+ /**
65
+ * Per-action outcome from the post-submit runner, in the order attempted.
66
+ * Only actions the form was configured to run appear; a form with no
67
+ * post-actions has an empty log.
68
+ */
69
+ readonly actionLog: readonly FormSubmissionActionEntry[]
29
70
  readonly createdAt: Date
30
71
  }
31
72
 
73
+ const actionEntrySchema = z
74
+ .object({
75
+ action: z.string(),
76
+ status: z.enum(FORM_ACTION_STATUSES),
77
+ detail: z.string(),
78
+ })
79
+ .transform(
80
+ (wire): FormSubmissionActionEntry => ({
81
+ action: wire.action,
82
+ status: wire.status,
83
+ detail: wire.detail,
84
+ }),
85
+ )
86
+
32
87
  export const formSubmissionSchema: z.ZodType<FormSubmission, unknown> = z
33
88
  .object({
34
89
  id: idSchema,
@@ -39,6 +94,10 @@ export const formSubmissionSchema: z.ZodType<FormSubmission, unknown> = z
39
94
  company_id: idSchema.nullable(),
40
95
  position_id: idSchema.nullable(),
41
96
  deal_id: idSchema.nullable(),
97
+ opportunity_id: idSchema.nullable(),
98
+ partnership_id: idSchema.nullable(),
99
+ enquiry_id: idSchema.nullable(),
100
+ action_log: z.array(actionEntrySchema),
42
101
  created_at: timestampSchema,
43
102
  })
44
103
  .transform(
@@ -51,6 +110,10 @@ export const formSubmissionSchema: z.ZodType<FormSubmission, unknown> = z
51
110
  companyId: wire.company_id,
52
111
  positionId: wire.position_id,
53
112
  dealId: wire.deal_id,
113
+ opportunityId: wire.opportunity_id,
114
+ partnershipId: wire.partnership_id,
115
+ enquiryId: wire.enquiry_id,
116
+ actionLog: wire.action_log,
54
117
  createdAt: wire.created_at,
55
118
  }),
56
119
  )
@@ -115,12 +115,22 @@ export interface CsvColumn {
115
115
  /**
116
116
  * A column an import may map but an export never writes.
117
117
  *
118
- * The People affiliation columns are the only ones: a person can hold many
119
- * positions, so a single company and title on the person row would be lossy on
120
- * the way out. They map on the way in to drive a Position, and the export and
121
- * template leave them out. See `headersFor`.
118
+ * The People affiliation columns are the original example: a person can hold
119
+ * many positions, so a single company and title on the person row would be
120
+ * lossy on the way out. They map on the way in to drive a Position, and the
121
+ * export and template leave them out. The consent grant columns follow the
122
+ * same rule — a person's consent state is exported serialised in `consents`,
123
+ * not row-by-row per purpose. See `headersFor`.
122
124
  */
123
125
  readonly importOnly?: boolean
126
+ /**
127
+ * A column an export writes but an import never maps. `do_not_contact` and
128
+ * the serialised `consents` list are exported for readability; consent
129
+ * round-trips through `consent_status` / `consent_at` plus the job's
130
+ * `consent_purpose_id`, and `do_not_contact` is a Person patch, not part of
131
+ * an import job.
132
+ */
133
+ readonly exportOnly?: boolean
124
134
  }
125
135
 
126
136
  /**
@@ -133,6 +143,14 @@ export interface CsvColumn {
133
143
  * still carry `company_domain`, `company_name` and `title`, which drive a
134
144
  * Position for the person rather than a field on them. Those three are
135
145
  * `importOnly`, so an export and a template leave them out.
146
+ *
147
+ * A People row still has to name its person, but `name` is not marked `required`
148
+ * here: the row satisfies it with `name`, or with `first_name` / `last_name` for
149
+ * the name to be composed from. `required` is a per-column flag and that is a
150
+ * statement about a pair of them, so the rule lives in the People row check
151
+ * instead. Every CRM worth migrating from exports first and last name in their
152
+ * own columns and most export no full name at all, which is the case this
153
+ * exists for.
136
154
  */
137
155
  export const OBJECT_COLUMNS: Readonly<Record<ImportObject, readonly CsvColumn[]>> = {
138
156
  companies: [
@@ -150,7 +168,11 @@ export const OBJECT_COLUMNS: Readonly<Record<ImportObject, readonly CsvColumn[]>
150
168
  { key: 'hq', label: 'HQ', required: false },
151
169
  ],
152
170
  people: [
153
- { key: 'name', label: 'Name', required: true },
171
+ { key: 'name', label: 'Name', required: false },
172
+ { key: 'salutation', label: 'Salutation', required: false },
173
+ { key: 'first_name', label: 'First name', required: false },
174
+ { key: 'last_name', label: 'Last name', required: false },
175
+ { key: 'suffix', label: 'Suffix', required: false },
154
176
  { key: 'email', label: 'Email', required: true },
155
177
  { key: 'timezone', label: 'Timezone', required: false },
156
178
  { key: 'location', label: 'Location', required: false },
@@ -160,9 +182,18 @@ export const OBJECT_COLUMNS: Readonly<Record<ImportObject, readonly CsvColumn[]>
160
182
  { key: 'summary', label: 'Summary', required: false },
161
183
  { key: 'tags', label: 'Tags', required: false },
162
184
  { key: 'phones', label: 'Phones', required: false },
185
+ { key: 'do_not_contact', label: 'Do not contact', required: false, exportOnly: true },
186
+ { key: 'consents', label: 'Consents', required: false, exportOnly: true },
163
187
  { key: 'company_domain', label: 'Company domain', required: false, importOnly: true },
164
188
  { key: 'company_name', label: 'Company name', required: false, importOnly: true },
165
189
  { key: 'title', label: 'Title', required: false, importOnly: true },
190
+ /**
191
+ * Consent grant to write for the job's purpose. `granted` or `withdrawn`.
192
+ * Mapping either column with no `consent_purpose_id` set on the job is a
193
+ * `422` — the writer would not know which purpose the row is for.
194
+ */
195
+ { key: 'consent_status', label: 'Consent status', required: false, importOnly: true },
196
+ { key: 'consent_at', label: 'Consent at', required: false, importOnly: true },
166
197
  ],
167
198
  positions: [
168
199
  { key: 'person_email', label: 'Person email', required: true },
@@ -298,6 +329,12 @@ export interface ImportJob extends RecordTimestamps {
298
329
  readonly onMissingCompany: OnMissingCompany
299
330
  readonly matchKey: string
300
331
  readonly columnMap: ImportColumnMap
332
+ /**
333
+ * The consent purpose a People import grants (or withdraws) for each row
334
+ * that carries a `consent_status`. Required whenever the map names
335
+ * `consent_status` or `consent_at`. Null for every other object.
336
+ */
337
+ readonly consentPurposeId: string | null
301
338
  /** The headers as they appeared in the uploaded file, in file order. */
302
339
  readonly sourceHeaders: readonly string[]
303
340
  readonly fileName: string | null
@@ -348,6 +385,7 @@ export const importJobSchema: z.ZodType<ImportJob, unknown> = z
348
385
  on_missing_company: z.enum(ON_MISSING_COMPANY),
349
386
  match_key: z.string(),
350
387
  column_map: z.record(z.string(), z.string().nullable()),
388
+ consent_purpose_id: idSchema.nullable(),
351
389
  source_headers: z.array(z.string()),
352
390
  file_name: z.string().nullable(),
353
391
  counts: countsSchema,
@@ -366,6 +404,7 @@ export const importJobSchema: z.ZodType<ImportJob, unknown> = z
366
404
  onMissingCompany: wire.on_missing_company,
367
405
  matchKey: wire.match_key,
368
406
  columnMap: wire.column_map,
407
+ consentPurposeId: wire.consent_purpose_id,
369
408
  sourceHeaders: wire.source_headers,
370
409
  fileName: wire.file_name,
371
410
  counts: wire.counts,