@kelpie/schemas 0.7.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 (111) hide show
  1. package/dist/account.d.ts +35 -0
  2. package/dist/account.d.ts.map +1 -1
  3. package/dist/account.js +18 -0
  4. package/dist/account.js.map +1 -1
  5. package/dist/company.d.ts +11 -0
  6. package/dist/company.d.ts.map +1 -1
  7. package/dist/company.js +7 -0
  8. package/dist/company.js.map +1 -1
  9. package/dist/consentPurpose.d.ts +49 -0
  10. package/dist/consentPurpose.d.ts.map +1 -0
  11. package/dist/consentPurpose.js +40 -0
  12. package/dist/consentPurpose.js.map +1 -0
  13. package/dist/customField.d.ts +99 -0
  14. package/dist/customField.d.ts.map +1 -0
  15. package/dist/customField.js +133 -0
  16. package/dist/customField.js.map +1 -0
  17. package/dist/deal.d.ts +8 -0
  18. package/dist/deal.d.ts.map +1 -1
  19. package/dist/deal.js +4 -0
  20. package/dist/deal.js.map +1 -1
  21. package/dist/emailDomainLinker.d.ts +16 -0
  22. package/dist/emailDomainLinker.d.ts.map +1 -0
  23. package/dist/emailDomainLinker.js +11 -0
  24. package/dist/emailDomainLinker.js.map +1 -0
  25. package/dist/enquiry.d.ts +43 -0
  26. package/dist/enquiry.d.ts.map +1 -0
  27. package/dist/enquiry.js +47 -0
  28. package/dist/enquiry.js.map +1 -0
  29. package/dist/form.d.ts +117 -2
  30. package/dist/form.d.ts.map +1 -1
  31. package/dist/form.js +102 -1
  32. package/dist/form.js.map +1 -1
  33. package/dist/formSubmission.d.ts +29 -0
  34. package/dist/formSubmission.d.ts.map +1 -1
  35. package/dist/formSubmission.js +34 -0
  36. package/dist/formSubmission.js.map +1 -1
  37. package/dist/importExport.d.ts +28 -4
  38. package/dist/importExport.d.ts.map +1 -1
  39. package/dist/importExport.js +25 -2
  40. package/dist/importExport.js.map +1 -1
  41. package/dist/index.d.ts +27 -7
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +13 -3
  44. package/dist/index.js.map +1 -1
  45. package/dist/list.d.ts +44 -0
  46. package/dist/list.d.ts.map +1 -0
  47. package/dist/list.js +41 -0
  48. package/dist/list.js.map +1 -0
  49. package/dist/listMember.d.ts +24 -0
  50. package/dist/listMember.d.ts.map +1 -0
  51. package/dist/listMember.js +27 -0
  52. package/dist/listMember.js.map +1 -0
  53. package/dist/listMembership.d.ts +20 -0
  54. package/dist/listMembership.d.ts.map +1 -0
  55. package/dist/listMembership.js +23 -0
  56. package/dist/listMembership.js.map +1 -0
  57. package/dist/names.d.ts +43 -0
  58. package/dist/names.d.ts.map +1 -0
  59. package/dist/names.js +35 -0
  60. package/dist/names.js.map +1 -0
  61. package/dist/opportunity.d.ts +10 -0
  62. package/dist/opportunity.d.ts.map +1 -1
  63. package/dist/opportunity.js +7 -0
  64. package/dist/opportunity.js.map +1 -1
  65. package/dist/partnership.d.ts +8 -0
  66. package/dist/partnership.d.ts.map +1 -1
  67. package/dist/partnership.js +4 -0
  68. package/dist/partnership.js.map +1 -1
  69. package/dist/person.d.ts +42 -1
  70. package/dist/person.d.ts.map +1 -1
  71. package/dist/person.js +23 -0
  72. package/dist/person.js.map +1 -1
  73. package/dist/personConsent.d.ts +31 -0
  74. package/dist/personConsent.d.ts.map +1 -0
  75. package/dist/personConsent.js +27 -0
  76. package/dist/personConsent.js.map +1 -0
  77. package/dist/raise.d.ts +8 -0
  78. package/dist/raise.d.ts.map +1 -1
  79. package/dist/raise.js +4 -0
  80. package/dist/raise.js.map +1 -1
  81. package/dist/sampleData.d.ts +23 -0
  82. package/dist/sampleData.d.ts.map +1 -0
  83. package/dist/sampleData.js +31 -0
  84. package/dist/sampleData.js.map +1 -0
  85. package/dist/values.d.ts +88 -8
  86. package/dist/values.d.ts.map +1 -1
  87. package/dist/values.js +150 -3
  88. package/dist/values.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/account.ts +56 -0
  91. package/src/company.ts +19 -0
  92. package/src/consentPurpose.ts +95 -0
  93. package/src/customField.ts +211 -0
  94. package/src/deal.ts +13 -0
  95. package/src/emailDomainLinker.ts +28 -0
  96. package/src/enquiry.ts +94 -0
  97. package/src/form.ts +230 -2
  98. package/src/formSubmission.ts +63 -0
  99. package/src/importExport.ts +45 -6
  100. package/src/index.ts +86 -2
  101. package/src/list.ts +89 -0
  102. package/src/listMember.ts +54 -0
  103. package/src/listMembership.ts +45 -0
  104. package/src/names.ts +49 -0
  105. package/src/opportunity.ts +18 -0
  106. package/src/partnership.ts +13 -0
  107. package/src/person.ts +69 -1
  108. package/src/personConsent.ts +60 -0
  109. package/src/raise.ts +13 -0
  110. package/src/sampleData.ts +55 -0
  111. package/src/values.ts +180 -3
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod'
2
+
3
+ import { CONSENT_PURPOSE_STATUSES } from './values.ts'
4
+ import type { ConsentPurposeStatus, ConsentStatus } from './values.ts'
5
+ import { definedFields, nullableTimestampSchema } from './wire.ts'
6
+
7
+ /**
8
+ * The effective consent status for a (person, purpose) pair.
9
+ *
10
+ * `inherited: true` means the person has no explicit `person_consents` row for
11
+ * the purpose and the reader is looking at the purpose's `default_status`. In
12
+ * that case `source` and `notedAt` are null. `inherited: false` means the row
13
+ * exists and carries its own `granted | withdrawn`, `source`, and `notedAt`.
14
+ */
15
+ export interface PersonConsent {
16
+ /** The purpose's slug — the identity that survives a rename of its label. */
17
+ readonly purposeSlug: string
18
+ readonly purposeLabel: string
19
+ readonly status: ConsentPurposeStatus
20
+ readonly source: string | null
21
+ readonly notedAt: Date | null
22
+ readonly inherited: boolean
23
+ }
24
+
25
+ export const personConsentSchema: z.ZodType<PersonConsent, unknown> = z
26
+ .object({
27
+ purpose_slug: z.string(),
28
+ purpose_label: z.string(),
29
+ status: z.enum(CONSENT_PURPOSE_STATUSES),
30
+ source: z.string().nullable(),
31
+ noted_at: nullableTimestampSchema,
32
+ inherited: z.boolean(),
33
+ })
34
+ .transform(
35
+ (wire): PersonConsent => ({
36
+ purposeSlug: wire.purpose_slug,
37
+ purposeLabel: wire.purpose_label,
38
+ status: wire.status,
39
+ source: wire.source,
40
+ notedAt: wire.noted_at,
41
+ inherited: wire.inherited,
42
+ }),
43
+ )
44
+
45
+ /**
46
+ * What a manual override writes for one (person, purpose) pair. A `status` of
47
+ * `granted` or `withdrawn` upserts the row (`source: manual`); a `null` status
48
+ * clears the row and falls the pair back to the purpose's default.
49
+ */
50
+ export interface PersonConsentInput {
51
+ readonly purposeSlug: string
52
+ readonly status: ConsentStatus | null
53
+ }
54
+
55
+ export function personConsentWriteBody(input: PersonConsentInput): Record<string, unknown> {
56
+ return definedFields({
57
+ purpose_slug: input.purposeSlug,
58
+ status: input.status,
59
+ })
60
+ }
package/src/raise.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { customFieldValuesBody, customFieldValuesSchema } from './customField.ts'
4
+ import type { CustomFieldValue, CustomFieldValues } from './customField.ts'
3
5
  import { definedFields, idSchema, recordTimestamps } from './wire.ts'
4
6
  import type { RecordTimestamps } from './wire.ts'
5
7
 
@@ -22,6 +24,8 @@ export interface Raise extends RecordTimestamps {
22
24
  readonly personIds: readonly string[]
23
25
  readonly summary: string
24
26
  readonly tags: readonly string[]
27
+ /** Workspace-defined fields, keyed by definition key. Always present (default `{}`). */
28
+ readonly customFields: CustomFieldValues
25
29
  }
26
30
 
27
31
  export const raiseSchema: z.ZodType<Raise, unknown> = z
@@ -39,6 +43,7 @@ export const raiseSchema: z.ZodType<Raise, unknown> = z
39
43
  person_ids: z.array(idSchema),
40
44
  summary: z.string(),
41
45
  tags: z.array(z.string()),
46
+ custom_fields: customFieldValuesSchema,
42
47
  ...recordTimestamps,
43
48
  })
44
49
  .transform(
@@ -56,6 +61,7 @@ export const raiseSchema: z.ZodType<Raise, unknown> = z
56
61
  personIds: wire.person_ids,
57
62
  summary: wire.summary,
58
63
  tags: wire.tags,
64
+ customFields: wire.custom_fields,
59
65
  createdAt: wire.created_at,
60
66
  updatedAt: wire.updated_at,
61
67
  }),
@@ -74,6 +80,11 @@ export interface RaiseInput {
74
80
  readonly personIds?: readonly string[]
75
81
  readonly summary?: string
76
82
  readonly tags?: readonly string[]
83
+ /**
84
+ * Partial merge patch: sent keys change, `null` clears a key, absent keys are
85
+ * left alone. Unknown keys are rejected at `422`.
86
+ */
87
+ readonly customFields?: Readonly<Record<string, CustomFieldValue | null>>
77
88
  }
78
89
 
79
90
  export function raiseBody(input: RaiseInput): Record<string, unknown> {
@@ -90,5 +101,7 @@ export function raiseBody(input: RaiseInput): Record<string, unknown> {
90
101
  person_ids: input.personIds,
91
102
  summary: input.summary,
92
103
  tags: input.tags,
104
+ custom_fields:
105
+ input.customFields === undefined ? undefined : customFieldValuesBody(input.customFields),
93
106
  })
94
107
  }
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Wire shape for `POST /v1/workspaces/:id/sample-data`.
5
+ *
6
+ * The endpoint takes no body. The response is a count per object type, so a
7
+ * caller can tell the reader what the button just did without a second query.
8
+ */
9
+
10
+ export interface SampleDataCounts {
11
+ readonly companies: number
12
+ readonly people: number
13
+ readonly positions: number
14
+ readonly deals: number
15
+ readonly planItems: number
16
+ readonly notes: number
17
+ readonly opportunities: number
18
+ readonly raises: number
19
+ readonly partnerships: number
20
+ readonly enquiries: number
21
+ readonly roles: number
22
+ readonly candidates: number
23
+ }
24
+
25
+ export const sampleDataCountsSchema: z.ZodType<SampleDataCounts, unknown> = z
26
+ .object({
27
+ companies: z.number().int().nonnegative(),
28
+ people: z.number().int().nonnegative(),
29
+ positions: z.number().int().nonnegative(),
30
+ deals: z.number().int().nonnegative(),
31
+ plan_items: z.number().int().nonnegative(),
32
+ notes: z.number().int().nonnegative(),
33
+ opportunities: z.number().int().nonnegative(),
34
+ raises: z.number().int().nonnegative(),
35
+ partnerships: z.number().int().nonnegative(),
36
+ enquiries: z.number().int().nonnegative(),
37
+ roles: z.number().int().nonnegative(),
38
+ candidates: z.number().int().nonnegative(),
39
+ })
40
+ .transform(
41
+ (wire): SampleDataCounts => ({
42
+ companies: wire.companies,
43
+ people: wire.people,
44
+ positions: wire.positions,
45
+ deals: wire.deals,
46
+ planItems: wire.plan_items,
47
+ notes: wire.notes,
48
+ opportunities: wire.opportunities,
49
+ raises: wire.raises,
50
+ partnerships: wire.partnerships,
51
+ enquiries: wire.enquiries,
52
+ roles: wire.roles,
53
+ candidates: wire.candidates,
54
+ }),
55
+ )
package/src/values.ts CHANGED
@@ -90,6 +90,7 @@ export const EXTENSIBLE_RECORD_TYPES = [
90
90
  'opportunity',
91
91
  'partnership',
92
92
  'raise',
93
+ 'enquiry',
93
94
  'role',
94
95
  'candidate',
95
96
  ] as const
@@ -110,11 +111,24 @@ export const RECORD_TARGET_TYPES = [
110
111
  'opportunity',
111
112
  'partnership',
112
113
  'raise',
114
+ 'enquiry',
113
115
  'candidate',
114
116
  ] as const
115
117
 
116
118
  export type RecordTargetType = (typeof RECORD_TARGET_TYPES)[number]
117
119
 
120
+ /** Human labels for `RECORD_TARGET_TYPES`. Used by any picker that names a type. */
121
+ export const RECORD_TARGET_TYPE_LABELS: Readonly<Record<RecordTargetType, string>> = {
122
+ person: 'Person',
123
+ company: 'Company',
124
+ deal: 'Deal',
125
+ opportunity: 'Opportunity',
126
+ partnership: 'Partnership',
127
+ raise: 'Raise',
128
+ enquiry: 'Enquiry',
129
+ candidate: 'Candidate',
130
+ }
131
+
118
132
  /**
119
133
  * What `GET /v1/search` looks through, and the order its groups come back in.
120
134
  *
@@ -131,6 +145,7 @@ export const SEARCH_COLLECTIONS = [
131
145
  'person',
132
146
  'role',
133
147
  'company',
148
+ 'enquiry',
134
149
  'deal',
135
150
  'opportunity',
136
151
  'raise',
@@ -196,16 +211,23 @@ export const INTERVIEW_STAGE_LABELS: Readonly<Record<InterviewStage, string>> =
196
211
  export const FIRST_INTERVIEW_STAGE: InterviewStage = INTERVIEW_STAGES[0]
197
212
 
198
213
  /**
199
- * The four pipelines whose board columns live in `pipeline_stages`. A Deal moves
214
+ * The five pipelines whose board columns live in `pipeline_stages`. A Deal moves
200
215
  * through `deal` stages and so on; the kinds are fixed even though the stages
201
216
  * within each are workspace-configurable.
202
217
  */
203
- export const PIPELINE_KINDS = ['deal', 'opportunity', 'raise', 'partnership'] as const
218
+ export const PIPELINE_KINDS = [
219
+ 'enquiry',
220
+ 'deal',
221
+ 'opportunity',
222
+ 'raise',
223
+ 'partnership',
224
+ ] as const
204
225
 
205
226
  export type PipelineKind = (typeof PIPELINE_KINDS)[number]
206
227
 
207
228
  /** Display names for `PIPELINE_KINDS`. "Fundraising" is what the nav calls a Raise. */
208
229
  export const PIPELINE_KIND_LABELS: Readonly<Record<PipelineKind, string>> = {
230
+ enquiry: 'Enquiry',
209
231
  deal: 'Deal',
210
232
  opportunity: 'Opportunity',
211
233
  raise: 'Fundraising',
@@ -302,24 +324,46 @@ export type FormStatus = (typeof FORM_STATUSES)[number]
302
324
  * uploads, multi-page forms and branching out of scope, and every type here is
303
325
  * one `<input>`, `<textarea>` or `<select>`.
304
326
  */
305
- export const FORM_FIELD_TYPES = ['text', 'email', 'textarea', 'select'] as const
327
+ export const FORM_FIELD_TYPES = ['text', 'email', 'textarea', 'select', 'consent', 'notice'] as const
306
328
 
307
329
  export type FormFieldType = (typeof FORM_FIELD_TYPES)[number]
308
330
 
331
+ /**
332
+ * The field types whose answer is stored as a boolean, not a string. A
333
+ * `consent` field renders as a checkbox whose label carries the consent
334
+ * statement; the answer records whether the visitor ticked it.
335
+ */
336
+ export const FORM_BOOLEAN_FIELD_TYPES = ['consent'] as const
337
+
338
+ export type FormBooleanFieldType = (typeof FORM_BOOLEAN_FIELD_TYPES)[number]
339
+
309
340
  /**
310
341
  * Where a field's answer lands on submit.
311
342
  *
312
343
  * `position.title` rather than a person field, because a job title belongs to the
313
344
  * Person↔Company link and nowhere else. `submission` stores the answer without
314
345
  * writing any CRM record, which is what a free-text "How can we help?" wants.
346
+ *
347
+ * A form asks for a name the way its author wants it asked: one `person.name`
348
+ * box, or the `person.first_name` / `person.last_name` pair most sign-up forms
349
+ * use. Mapping the pair and no `person.name` composes the display name from the
350
+ * two, so neither arrangement makes a nameless person. `salutation` and `suffix`
351
+ * are storable on a Person but are not offered here — a public form asking for
352
+ * them is rare enough that the choice is not worth the room in this list.
315
353
  */
316
354
  export const FORM_FIELD_MAP_TARGETS = [
317
355
  'person.name',
356
+ 'person.first_name',
357
+ 'person.last_name',
318
358
  'person.email',
359
+ 'person.consent',
319
360
  'company.name',
320
361
  'company.domain',
321
362
  'position.title',
363
+ 'enquiry.name',
322
364
  'deal.name',
365
+ 'opportunity.name',
366
+ 'partnership.name',
323
367
  'submission',
324
368
  ] as const
325
369
 
@@ -327,14 +371,23 @@ export type FormFieldMapTarget = (typeof FORM_FIELD_MAP_TARGETS)[number]
327
371
 
328
372
  export const FORM_FIELD_MAP_TARGET_LABELS: Readonly<Record<FormFieldMapTarget, string>> = {
329
373
  'person.name': 'Person · name',
374
+ 'person.first_name': 'Person · first name',
375
+ 'person.last_name': 'Person · last name',
330
376
  'person.email': 'Person · email',
377
+ 'person.consent': 'Person · consent',
331
378
  'company.name': 'Company · name',
332
379
  'company.domain': 'Company · domain',
333
380
  'position.title': 'Position · title',
381
+ 'enquiry.name': 'Enquiry · name',
334
382
  'deal.name': 'Deal · name',
383
+ 'opportunity.name': 'Opportunity · name',
384
+ 'partnership.name': 'Partnership · name',
335
385
  submission: 'Submission only',
336
386
  }
337
387
 
388
+ /** The map target for a consent field. Repeats per purpose, unlike `person.email`. */
389
+ export const PERSON_CONSENT_TARGET: FormFieldMapTarget = 'person.consent'
390
+
338
391
  /** The one mapping a form cannot process without, and may carry at most once. */
339
392
  export const PERSON_EMAIL_TARGET: FormFieldMapTarget = 'person.email'
340
393
 
@@ -348,6 +401,16 @@ export const FORM_OPTION_VALUE_TYPES = ['string', 'number', 'boolean'] as const
348
401
 
349
402
  export type FormOptionValueType = (typeof FORM_OPTION_VALUE_TYPES)[number]
350
403
 
404
+ /**
405
+ * Each post-submit action lands one of these on the submission's `action_log`.
406
+ * `ok` = the action ran; `skipped` = its precondition was absent (a company
407
+ * list on a submit that never resolved one); `error` = the savepoint rolled
408
+ * back so the rest of the submit could continue.
409
+ */
410
+ export const FORM_ACTION_STATUSES = ['ok', 'skipped', 'error'] as const
411
+
412
+ export type FormActionStatus = (typeof FORM_ACTION_STATUSES)[number]
413
+
351
414
  /**
352
415
  * The domain events a webhook can subscribe to.
353
416
  *
@@ -404,6 +467,7 @@ export const AGENT_TASK_TARGET_TYPES = [
404
467
  'opportunity',
405
468
  'partnership',
406
469
  'raise',
470
+ 'enquiry',
407
471
  'candidate',
408
472
  'role',
409
473
  'handbook',
@@ -433,3 +497,116 @@ export const AGENT_RUN_STATUS_LABELS: Readonly<Record<AgentRunStatus, string>> =
433
497
  succeeded: 'Succeeded',
434
498
  failed: 'Failed',
435
499
  }
500
+
501
+ /**
502
+ * The seven record types a workspace may attach custom field definitions to.
503
+ *
504
+ * The ones that already carry `tags` and agent-oriented fields on the record
505
+ * itself. Role and Candidate are absent on purpose: hiring state hangs off the
506
+ * Candidate link and a Role is a header rather than a rich record, so the demand
507
+ * signal for custom fields on either has not landed. Kept separate from
508
+ * `RECORD_TARGET_TYPES` and `EXTENSIBLE_RECORD_TYPES` so a later addition here
509
+ * cannot silently widen either of those.
510
+ */
511
+ export const CUSTOM_FIELD_OBJECT_TYPES = [
512
+ 'person',
513
+ 'company',
514
+ 'deal',
515
+ 'opportunity',
516
+ 'partnership',
517
+ 'raise',
518
+ 'enquiry',
519
+ ] as const
520
+
521
+ export type CustomFieldObjectType = (typeof CUSTOM_FIELD_OBJECT_TYPES)[number]
522
+
523
+ export const CUSTOM_FIELD_OBJECT_TYPE_LABELS: Readonly<Record<CustomFieldObjectType, string>> = {
524
+ person: 'Person',
525
+ company: 'Company',
526
+ deal: 'Deal',
527
+ opportunity: 'Opportunity',
528
+ partnership: 'Partnership',
529
+ raise: 'Raise',
530
+ enquiry: 'Enquiry',
531
+ }
532
+
533
+ /**
534
+ * The nine editor types a custom field can carry.
535
+ *
536
+ * Deliberately distinct from `FORM_FIELD_TYPES`: a form asks a stranger for four
537
+ * kinds of text over one submit; a custom field is a first-class attribute on a
538
+ * record with numeric, date, boolean and currency answers. Sharing one list
539
+ * would drag either surface toward the other's shape.
540
+ *
541
+ * `record_reference` is intentionally absent from v1 — it needs the polymorphic
542
+ * existence check and delete-transaction cleanup that Phase 2 owns.
543
+ */
544
+ export const CUSTOM_FIELD_TYPES = [
545
+ 'text',
546
+ 'long_text',
547
+ 'number',
548
+ 'currency',
549
+ 'date',
550
+ 'checkbox',
551
+ 'select',
552
+ 'multi_select',
553
+ 'url',
554
+ ] as const
555
+
556
+ export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number]
557
+
558
+ export const CUSTOM_FIELD_TYPE_LABELS: Readonly<Record<CustomFieldType, string>> = {
559
+ text: 'Text',
560
+ long_text: 'Long text',
561
+ number: 'Number',
562
+ currency: 'Currency',
563
+ date: 'Date',
564
+ checkbox: 'Checkbox',
565
+ select: 'Select',
566
+ multi_select: 'Multi-select',
567
+ url: 'URL',
568
+ }
569
+
570
+ /**
571
+ * The custom-field types whose definition carries an `options` list. Every
572
+ * other type stores its `options` as `[]` and any non-empty write is `422`.
573
+ */
574
+ export const CUSTOM_FIELD_TYPES_WITH_OPTIONS = ['select', 'multi_select'] as const
575
+
576
+ export type CustomFieldTypeWithOptions = (typeof CUSTOM_FIELD_TYPES_WITH_OPTIONS)[number]
577
+
578
+ /**
579
+ * The default a consent purpose starts with, and what a person without an
580
+ * explicit `person_consents` row inherits for that purpose. `unknown` is the
581
+ * safest default — silence about a person's wishes is not a grant.
582
+ */
583
+ export const CONSENT_PURPOSE_STATUSES = ['unknown', 'granted', 'withdrawn'] as const
584
+
585
+ export type ConsentPurposeStatus = (typeof CONSENT_PURPOSE_STATUSES)[number]
586
+
587
+ export const CONSENT_PURPOSE_STATUS_LABELS: Readonly<Record<ConsentPurposeStatus, string>> = {
588
+ unknown: 'Unknown',
589
+ granted: 'Granted',
590
+ withdrawn: 'Withdrawn',
591
+ }
592
+
593
+ /**
594
+ * The explicit status on a `person_consents` row. `unknown` is deliberately
595
+ * absent: no row is the unknown, so a stored row always carries a decision.
596
+ */
597
+ export const CONSENT_STATUSES = ['granted', 'withdrawn'] as const
598
+
599
+ export type ConsentStatus = (typeof CONSENT_STATUSES)[number]
600
+
601
+ export const CONSENT_STATUS_LABELS: Readonly<Record<ConsentStatus, string>> = {
602
+ granted: 'Granted',
603
+ withdrawn: 'Withdrawn',
604
+ }
605
+
606
+ /**
607
+ * Where a `person_consents` row came from. `form:<form_id>` and `list:<list_id>`
608
+ * are prefixed so the origin is inspectable; `import` and `manual` need no id.
609
+ */
610
+ export const CONSENT_SOURCE_KINDS = ['form', 'list', 'import', 'manual'] as const
611
+
612
+ export type ConsentSourceKind = (typeof CONSENT_SOURCE_KINDS)[number]