@evinvest/sites-contract 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,502 @@
1
+ export declare const SCHEMA = "sa.funnel.v1";
2
+ /**
3
+ * booking.attached@1 — an operator joined a provider's booking to the lead (subject.lead_id):
4
+ * one the panel could not match, or one matched by contact that they confirm or correct.
5
+ */
6
+ export type BookingAttachedV1 = {
7
+ provider: string;
8
+ external_ref: string;
9
+ };
10
+ /**
11
+ * booking.canceled@1 — a provider says a booking it reported is canceled. As booking.created.
12
+ */
13
+ export type BookingCanceledV1 = {
14
+ provider: string;
15
+ external_ref: string;
16
+ version: string;
17
+ };
18
+ /**
19
+ * booking.cleared@1 — an operator took the lead's slot away: no booking any more.
20
+ */
21
+ export type BookingClearedV1 = Record<string, never>;
22
+ /**
23
+ * booking.created@1 — a provider says a slot is booked, or moved (the same external_ref with
24
+ * another start): written by the panel's adapters only (source.kind booking, source.id the
25
+ * provider). subject.lead_id: the lead it was matched to, absent while unmatched. The
26
+ * attendee's name, email and phone go in `pii`, never here.
27
+ */
28
+ export type BookingCreatedV1 = {
29
+ /**
30
+ * google_calendar | cal_com: a provider with an adapter.
31
+ */
32
+ provider: string;
33
+ /**
34
+ * The provider's id of the booking (Google: the calendar event's id). 1–1024 of
35
+ * [A-Za-z0-9._:-].
36
+ */
37
+ external_ref: string;
38
+ start_at: string;
39
+ end_at?: string;
40
+ /**
41
+ * ref | contact: how subject.lead_id was found; set exactly when it is.
42
+ */
43
+ match?: string;
44
+ /**
45
+ * The provider's revision of the booking (Google: the event's etag), 1–256 printable
46
+ * ASCII: the same revision seen twice is one event.
47
+ */
48
+ version: string;
49
+ /**
50
+ * When the customer booked, as the provider says (Google: the event's `created`).
51
+ */
52
+ booked_at?: string;
53
+ };
54
+ /**
55
+ * booking.requested@1 — from a site (source.kind site), after the lead's lead.created: the
56
+ * visitor asked for a slot through `provider`, with an optional wish. No free text, no null.
57
+ * subject.lead_id required, and `lead_ref` is that same id: the site's leadRef,
58
+ * `lead-<row>-<8 lowercase hex>`, row ≥ 1. Rules normative in
59
+ * contracts/vectors/booking/README.md. Arriving before its lead.created, the batch is answered
60
+ * 409 (`deferred`) for the outbox to send again.
61
+ */
62
+ export type BookingRequestedV1 = {
63
+ lead_ref: string;
64
+ provider: string;
65
+ /**
66
+ * "YYYY-MM-DD", a real day, 2 days back to 366 ahead of its arrival; `manual` only.
67
+ */
68
+ preferred_date?: string;
69
+ /**
70
+ * morning | afternoon | evening; `manual` only.
71
+ */
72
+ preferred_part?: string;
73
+ };
74
+ /**
75
+ * booking.set@1 — an operator put the lead's slot in by hand (or moved it): it is booked.
76
+ * source.kind panel; subject.lead_id required.
77
+ */
78
+ export type BookingSetV1 = {
79
+ start_at: string;
80
+ end_at?: string;
81
+ };
82
+ /**
83
+ * booking.status_changed@1 — an operator closed a booked slot: done | no_show | canceled.
84
+ */
85
+ export type BookingStatusChangedV1 = {
86
+ status: string;
87
+ };
88
+ /**
89
+ * call.attempted@1 — an outgoing call started from the panel; subject.lead_id required.
90
+ */
91
+ export type CallAttemptedV1 = Record<string, never>;
92
+ /**
93
+ * call.logged@1 — how a call ended, as the operator tells it; subject.lead_id required.
94
+ */
95
+ export type CallLoggedV1 = {
96
+ /**
97
+ * answered | no_answer | wrong_number | later
98
+ */
99
+ outcome: string;
100
+ /**
101
+ * The id of the call.attempted event this closes, when there was one.
102
+ */
103
+ attempt_id?: string;
104
+ };
105
+ /**
106
+ * contact.metrics@1 — intents at a location (`contact_intent_click`) through one channel.
107
+ * subject.location_id as for site.metrics.
108
+ */
109
+ export type ContactMetricsV1 = {
110
+ day: string;
111
+ /**
112
+ * phone | whatsapp | form_open | booking
113
+ */
114
+ channel: string;
115
+ intents: number;
116
+ revision: number;
117
+ };
118
+ /**
119
+ * One fact about the funnel, as a source reports it.
120
+ */
121
+ export type Event = {
122
+ /**
123
+ * UUIDv7, issued by the source; the idempotency key. Resending an event with the same
124
+ * id and the same content is answered `duplicate`; with other content, `rejected`.
125
+ */
126
+ id: string;
127
+ /**
128
+ * Always "sa.funnel.v1".
129
+ */
130
+ schema: string;
131
+ /**
132
+ * What happened: "lead.created", "call.logged", … A type the panel does not know yet is
133
+ * stored as unregistered, not dropped, and picked up once it is registered.
134
+ */
135
+ type: string;
136
+ /**
137
+ * Version of `properties` for this type, 1 to 2147483647.
138
+ */
139
+ typeVersion: number;
140
+ /**
141
+ * When it happened in the world, not when it was sent.
142
+ */
143
+ occurredAt: string;
144
+ source: Source;
145
+ subject: Subject;
146
+ /**
147
+ * Checked against the message registered for `type@type_version` (the `…V<n>` messages
148
+ * below). May be omitted when that message has no required fields. At most 16 KiB as
149
+ * JSON, and no NUL character anywhere — for unregistered types too.
150
+ */
151
+ properties: {
152
+ [key: string]: unknown;
153
+ };
154
+ /**
155
+ * Name, phone, email, … — kept apart, encrypted at rest, never in reporting. Same limits
156
+ * as `properties`.
157
+ */
158
+ pii?: {
159
+ [key: string]: unknown;
160
+ };
161
+ };
162
+ export type EventResult = {
163
+ /**
164
+ * Position in the request.
165
+ */
166
+ index?: number;
167
+ /**
168
+ * The event's id as sent; empty when it had none.
169
+ */
170
+ id?: string;
171
+ /**
172
+ * accepted | duplicate | rejected | deferred (not journaled yet: it names what has not
173
+ * arrived; the whole batch is answered 409, to be sent again)
174
+ */
175
+ status?: string;
176
+ /**
177
+ * Why it was rejected; for an accepted event of an unregistered type, that it was stored
178
+ * without being projected.
179
+ */
180
+ reason?: string;
181
+ };
182
+ /**
183
+ * experiment.configured@1 — an admin's change to one of the brand's experiments, written by the
184
+ * panel (source.kind panel, source.id the admin's concierge id, or `cli`). subject: the brand
185
+ * alone. A field absent is left as it was; named in `reset`, it follows the declaration again.
186
+ */
187
+ export type ExperimentConfiguredV1 = {
188
+ key: string;
189
+ enabled?: boolean;
190
+ /**
191
+ * 2–32 of them, each ≥ 0, their sum > 0; empty: not set by this change.
192
+ */
193
+ weights: number[];
194
+ /**
195
+ * [0, 1).
196
+ */
197
+ holdout?: number;
198
+ /**
199
+ * enabled | weights | holdout.
200
+ */
201
+ reset: string[];
202
+ /**
203
+ * Who, as the screens show them: the admin's email, or their concierge id when concierge gave
204
+ * none (as a place's or a price list's history names them). An employee's, not PII. One line,
205
+ * 1–320 characters.
206
+ */
207
+ by: string;
208
+ };
209
+ export type ExperimentDeclaration = {
210
+ /**
211
+ * 1–64 of [a-z0-9_].
212
+ */
213
+ key: string;
214
+ /**
215
+ * 2–32 unique names of 1–32 [a-z0-9_-]; the first is the control.
216
+ */
217
+ variants: string[];
218
+ /**
219
+ * One per variant, each ≥ 0, their sum > 0.
220
+ */
221
+ weights: number[];
222
+ enabled: boolean;
223
+ /**
224
+ * [0, 1).
225
+ */
226
+ holdout?: number;
227
+ /**
228
+ * The hypothesis, at most 200 characters.
229
+ */
230
+ summary?: string;
231
+ };
232
+ /**
233
+ * experiment.metrics@1 — one variant of one of the brand's experiments, forced (QA) visits
234
+ * left out. No subject.location_id: the server-side lead event names none.
235
+ */
236
+ export type ExperimentMetricsV1 = {
237
+ day: string;
238
+ /**
239
+ * Lowercase slugs, as the landing names them ("quote_single_step", "b").
240
+ */
241
+ experiment: string;
242
+ variant: string;
243
+ /**
244
+ * Page views shown the variant (`experiment_exposed`).
245
+ */
246
+ exposures: number;
247
+ /**
248
+ * Leads the landing stored under it (`experiment_lead`).
249
+ */
250
+ leads: number;
251
+ /**
252
+ * Intents under it, by channel (`experiment_contact{channel}`).
253
+ */
254
+ phone: number;
255
+ whatsapp: number;
256
+ form_open: number;
257
+ booking: number;
258
+ revision: number;
259
+ };
260
+ /**
261
+ * experiments.declared@1 — what a brand's landing runs, as its code declares it; sent by the
262
+ * landing (source.kind site) at every start, a new id each time. subject: the brand alone (no
263
+ * location, lead or job). The panel keeps the latest by occurred_at; an experiment the latest
264
+ * does not name is retired.
265
+ */
266
+ export type ExperimentsDeclaredV1 = {
267
+ /**
268
+ * At most 64, each key once.
269
+ */
270
+ experiments: ExperimentDeclaration[];
271
+ };
272
+ /**
273
+ * The body of `POST /api/ingest/v1/events`.
274
+ */
275
+ export type IngestRequest = {
276
+ /**
277
+ * 1 to 500 events. Each is judged on its own.
278
+ */
279
+ events: Event[];
280
+ };
281
+ /**
282
+ * The body of the 207 answer: one result per event, in the request's order.
283
+ */
284
+ export type IngestResponse = {
285
+ results: EventResult[];
286
+ };
287
+ /**
288
+ * job.completed@1 — subject.lead_id and subject.job_id required.
289
+ */
290
+ export type JobCompletedV1 = Record<string, never>;
291
+ /**
292
+ * job.won@1 — subject.lead_id and subject.job_id required.
293
+ */
294
+ export type JobWonV1 = Record<string, never>;
295
+ /**
296
+ * lead.contacted@1 — subject.lead_id required.
297
+ */
298
+ export type LeadContactedV1 = {
299
+ /**
300
+ * phone | whatsapp | email | telegram | other
301
+ */
302
+ channel?: string;
303
+ };
304
+ /**
305
+ * lead.created@1 — subject.lead_id required. Written by a site, the panel, or a bot — a bot
306
+ * only with channel whatsapp or telegram.
307
+ */
308
+ export type LeadCreatedV1 = {
309
+ /**
310
+ * form | phone_inbound | callback | whatsapp | telegram
311
+ */
312
+ channel: string;
313
+ /**
314
+ * Who typed it in, for leads entered by hand (a panel user id).
315
+ */
316
+ entered_by?: string;
317
+ /**
318
+ * rate_limited | too_fast. Set by a landing whose antispam flagged the submission but
319
+ * kept it, since it may still be a real person: `rate_limited` — the visitor's address
320
+ * sent more than the landing allows in its window; `too_fast` — the form was sent sooner
321
+ * after it was shown than a person types. Absent for a lead nothing flagged. A submission
322
+ * caught by the honeypot is dropped by the landing and never sent.
323
+ */
324
+ suspect?: string;
325
+ /**
326
+ * The landing's flow for the need (FORM-VARIANTS-SPEC): quote | estimate | fixed. `quote`
327
+ * asks for a price; `estimate` showed one computed from the visitor's choices; `fixed` showed
328
+ * a fixed price. Absent: a landing from before the flows, the same as `quote`.
329
+ */
330
+ flow?: string;
331
+ /**
332
+ * The price shown, in integer cents, EUR TTC (no currency field yet), at least 0, as the
333
+ * landing's server computed it. With `pricing_valid_from`: both are required when `flow` is
334
+ * estimate or fixed, and refused with quote or no flow.
335
+ */
336
+ quoted_cents?: number;
337
+ /**
338
+ * The day the pricing model that gave `quoted_cents` took effect, RFC 3339 full-date
339
+ * ("2026-10-01").
340
+ */
341
+ pricing_valid_from?: string;
342
+ /**
343
+ * What the visitor picked for an estimate: input id → value id, the pricing model's slugs
344
+ * (1–40 of [a-z0-9_-] each), at most 12 pairs, never free text. Only with flow estimate.
345
+ */
346
+ estimate_inputs?: {
347
+ [key: string]: string;
348
+ };
349
+ /**
350
+ * The `distinct_id` of the landing's analytics beacon when the form was sent (a random id it
351
+ * holds in memory, `crypto.randomUUID()`): 1–128 of [A-Za-z0-9._:-]. Not PII. The panel
352
+ * sends the lead's later life to PostHog under it, so a visit and its lead are one person
353
+ * there. Absent: the panel uses `sa-lead:<brand>:<lead>`.
354
+ */
355
+ analytics_id?: string;
356
+ /**
357
+ * The reference the customer carries into a messenger (MESSENGER-CHANNELS-SPEC §1): the
358
+ * brand's 2–4 capital letters, a dash, 4–8 of Crockford base32 without I, L, O, U
359
+ * (`^[A-Z]{2,4}-[0-9A-HJKMNP-TV-Z]{4,8}$`, e.g. "AQ-7K3F"). Made by the landing's form, so
360
+ * not unique: `lead.messaged` and the lookup by ref name the brand's newest lead carrying
361
+ * it. Not PII.
362
+ */
363
+ message_ref?: string;
364
+ };
365
+ /**
366
+ * lead.lost@1 — subject.lead_id required.
367
+ */
368
+ export type LeadLostV1 = {
369
+ /**
370
+ * Lowercase slug: no_answer, too_expensive, out_of_area, …
371
+ */
372
+ reason: string;
373
+ note?: string;
374
+ };
375
+ /**
376
+ * lead.messaged@1 — the customer actually wrote the brand on a messenger. Written by a bot or
377
+ * the panel. subject.lead_id names the lead; or, absent, `message_ref` does, and the panel
378
+ * journals the event under the brand's newest lead carrying that ref (the lead id it found is
379
+ * what the journal keeps, so a rebuild does not look again). A ref no lead of the brand carries
380
+ * is `deferred` (the batch answered 409 with Retry-After, the event not journaled), the reason
381
+ * starting with `unknown_ref`: the landing's lead.created may not have arrived yet; send the
382
+ * same event again. Resolved by ref, the event takes the lead's location when it named none.
383
+ * A subject.lead_id the panel has no lead for is deferred the same way (`unknown_lead`). The
384
+ * customer's handle (a Telegram @username, a WhatsApp number or name) is PII: `pii.handle` /
385
+ * `pii.phone`, never here.
386
+ */
387
+ export type LeadMessagedV1 = {
388
+ /**
389
+ * whatsapp | telegram
390
+ */
391
+ channel: string;
392
+ /**
393
+ * As in LeadCreatedV1. Required when subject.lead_id is absent.
394
+ */
395
+ message_ref?: string;
396
+ };
397
+ /**
398
+ * lead.quoted@1 — subject.lead_id required.
399
+ */
400
+ export type LeadQuotedV1 = {
401
+ /**
402
+ * In minor units of `currency`; both or neither.
403
+ */
404
+ amount?: number;
405
+ /**
406
+ * ISO 4217, e.g. "EUR".
407
+ */
408
+ currency?: string;
409
+ };
410
+ /**
411
+ * payment.received@1 — subject.lead_id required.
412
+ */
413
+ export type PaymentReceivedV1 = {
414
+ /**
415
+ * What the customer paid, in minor units of `currency`; at least 0.
416
+ */
417
+ billed: number;
418
+ /**
419
+ * Our share of it, in the same units; 0 to `billed`.
420
+ */
421
+ commission: number;
422
+ /**
423
+ * ISO 4217, e.g. "EUR".
424
+ */
425
+ currency: string;
426
+ };
427
+ /** Every `type@version` the panel projects, and its properties. */
428
+ export type Registered = {
429
+ "lead.created@1": LeadCreatedV1;
430
+ "lead.messaged@1": LeadMessagedV1;
431
+ "lead.contacted@1": LeadContactedV1;
432
+ "lead.quoted@1": LeadQuotedV1;
433
+ "job.won@1": JobWonV1;
434
+ "lead.lost@1": LeadLostV1;
435
+ "job.completed@1": JobCompletedV1;
436
+ "payment.received@1": PaymentReceivedV1;
437
+ "call.attempted@1": CallAttemptedV1;
438
+ "call.logged@1": CallLoggedV1;
439
+ "site.metrics@1": SiteMetricsV1;
440
+ "contact.metrics@1": ContactMetricsV1;
441
+ "experiment.metrics@1": ExperimentMetricsV1;
442
+ "booking.requested@1": BookingRequestedV1;
443
+ "booking.created@1": BookingCreatedV1;
444
+ "booking.canceled@1": BookingCanceledV1;
445
+ "booking.set@1": BookingSetV1;
446
+ "booking.status_changed@1": BookingStatusChangedV1;
447
+ "booking.cleared@1": BookingClearedV1;
448
+ "booking.attached@1": BookingAttachedV1;
449
+ "experiments.declared@1": ExperimentsDeclaredV1;
450
+ "experiment.configured@1": ExperimentConfiguredV1;
451
+ };
452
+ /**
453
+ * site.metrics@1 — page views of a location (`location_page_view`) from one traffic source.
454
+ * subject.location_id: the location; absent for pages that name none.
455
+ */
456
+ export type SiteMetricsV1 = {
457
+ /**
458
+ * The UTC day, "2026-09-30".
459
+ */
460
+ day: string;
461
+ /**
462
+ * Lowercase, 1–64 of [a-z0-9._:-]: utm_source, the referrer's host, direct, internal,
463
+ * unknown, or "other" for whatever does not fit and for the tail past the 20 largest.
464
+ */
465
+ source: string;
466
+ visits: number;
467
+ /**
468
+ * 1, 2, …
469
+ */
470
+ revision: number;
471
+ };
472
+ export type Source = {
473
+ /**
474
+ * site | review_archive | gbp | posthog | panel | sheet | telephony | booking | bot. Must be
475
+ * the kind the signing key is registered for. `booking` is the panel's own booking adapters
476
+ * (a provider's webhook or its periodic pull): no key is ever issued for it. `bot` is a
477
+ * messenger bot or auto-responder (docs/BOT-API.md).
478
+ */
479
+ kind: string;
480
+ /**
481
+ * Which one. For a signed batch, the key id it is signed with ("aquafix-site"); anything
482
+ * else is rejected.
483
+ */
484
+ id: string;
485
+ };
486
+ /**
487
+ * What the event is about. Ids are opaque to the panel: 1–128 of [A-Za-z0-9._:-].
488
+ */
489
+ export type Subject = {
490
+ /**
491
+ * Lowercase slug, e.g. "aquafix". Must be one of the brands the signing key may write.
492
+ */
493
+ brandId: string;
494
+ locationId?: string;
495
+ /**
496
+ * Unique within the brand.
497
+ */
498
+ leadId?: string;
499
+ jobId?: string;
500
+ };
501
+ export declare const SITE_EVENTS: readonly ["lead.created@1", "booking.requested@1", "experiments.declared@1"];
502
+ export type SiteEventKey = (typeof SITE_EVENTS)[number];
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED by `nix run .#gen` — do not edit.
2
+ export const SCHEMA = "sa.funnel.v1";
3
+ export const SITE_EVENTS = ["lead.created@1", "booking.requested@1", "experiments.declared@1"];
@@ -0,0 +1,14 @@
1
+ export * from "./generated/booking.js";
2
+ export * from "./generated/codes.js";
3
+ export * from "./generated/experiments.js";
4
+ export * from "./generated/lead.js";
5
+ export * from "./generated/place.js";
6
+ export * from "./generated/pricing.js";
7
+ export type * from "./generated/types.js";
8
+ export * from "./generated/wire.js";
9
+ export { checkBookingConfig, checkBookingUrl, bookingRequested, isBookingProvider, isLeadRef, isPageProvider, preferredDateInWindow, type BookingProblem, type BookingRequested } from "./booking.js";
10
+ export { declarationProblem, parseLiveExperiments, type DeclarationProblem } from "./experiments.js";
11
+ export { ingestBody, isOpaqueId, siteEvent, uuidV7, type LeadCreated, type SiteEvent, type SiteEventHead, type SiteProperties } from "./ingest.js";
12
+ export { normalizePhone, phoneProblem } from "./phone.js";
13
+ export { checkPlaceLive, isInstant, type PlaceProblem } from "./place.js";
14
+ export { checkPricingModel, mulBp, priceOf, roundTo, type Price, type PriceLine, type PricingInputs, type PricingLabels, type PricingProblem } from "./pricing.js";
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export * from "./generated/booking.js";
2
+ export * from "./generated/codes.js";
3
+ export * from "./generated/experiments.js";
4
+ export * from "./generated/lead.js";
5
+ export * from "./generated/place.js";
6
+ export * from "./generated/pricing.js";
7
+ export * from "./generated/wire.js";
8
+ export { checkBookingConfig, checkBookingUrl, bookingRequested, isBookingProvider, isLeadRef, isPageProvider, preferredDateInWindow } from "./booking.js";
9
+ export { declarationProblem, parseLiveExperiments } from "./experiments.js";
10
+ export { ingestBody, isOpaqueId, siteEvent, uuidV7 } from "./ingest.js";
11
+ export { normalizePhone, phoneProblem } from "./phone.js";
12
+ export { checkPlaceLive, isInstant } from "./place.js";
13
+ export { checkPricingModel, mulBp, priceOf, roundTo } from "./pricing.js";
@@ -0,0 +1,51 @@
1
+ import type { BookingRequested } from "./booking.js";
2
+ import type { LeadFlow, LeadSuspect, SiteChannel } from "./generated/lead.js";
3
+ import { type Event, type ExperimentsDeclaredV1, type IngestRequest, type LeadCreatedV1, type Registered, type SiteEventKey, type Subject } from "./generated/wire.js";
4
+ /** `lead.created@1`'s properties, their vocabularies as the panel checks them. */
5
+ export type LeadCreated = LeadCreatedV1 & {
6
+ channel: SiteChannel;
7
+ suspect?: LeadSuspect;
8
+ flow?: LeadFlow;
9
+ };
10
+ /** The properties a site sends for each event it may write, the vocabularies narrowed. */
11
+ export type SiteProperties = {
12
+ "lead.created@1": LeadCreated;
13
+ "booking.requested@1": BookingRequested;
14
+ "experiments.declared@1": ExperimentsDeclaredV1;
15
+ } & {
16
+ [K in SiteEventKey]: Registered[K];
17
+ };
18
+ type TypeOf<K extends string> = K extends `${infer T}@${string}` ? T : never;
19
+ type VersionOf<K extends string> = K extends `${string}@${infer V extends number}` ? V : never;
20
+ /** An event a site sends: `Event` with its type, version and properties known. */
21
+ export type SiteEvent<K extends SiteEventKey> = Omit<Event, "type" | "typeVersion" | "properties"> & {
22
+ type: TypeOf<K>;
23
+ typeVersion: VersionOf<K>;
24
+ properties: SiteProperties[K];
25
+ };
26
+ /** What every event a site sends carries besides its properties. */
27
+ export interface SiteEventHead {
28
+ /** A UUIDv7 (`uuidV7`): the panel refuses another version, and keys the journal by it. */
29
+ id: string;
30
+ occurredAt: Date;
31
+ /** The key id the batch is signed with: the panel refuses any other `source.id`. */
32
+ sourceId: string;
33
+ subject: Subject;
34
+ }
35
+ /**
36
+ * One event as a site sends it (`contracts/vectors/ingest`), and the only way a site builds
37
+ * one: `pii` is what a person typed, kept apart — the panel seals it — and left out when
38
+ * empty.
39
+ */
40
+ export declare function siteEvent<K extends SiteEventKey>(key: K, head: SiteEventHead, properties: SiteProperties[K], pii?: Readonly<Record<string, string>>): SiteEvent<K>;
41
+ /** `POST /api/ingest/v1/events`' body. */
42
+ export declare const ingestBody: (...events: SiteEvent<SiteEventKey>[]) => IngestRequest;
43
+ /** An RFC 9562 UUIDv7 for `atMs`, its other 74 bits random. */
44
+ export declare function uuidV7(atMs: number): string;
45
+ /**
46
+ * An id the panel takes for a location, a lead or a job: 1–128 of `[A-Za-z0-9._:-]`, starting
47
+ * alphanumeric — and not made only of digits, dots and dashes with seven digits or more, which
48
+ * is what a phone number looks like: an id sits in the clear.
49
+ */
50
+ export declare function isOpaqueId(s: string): boolean;
51
+ export {};
package/dist/ingest.js ADDED
@@ -0,0 +1,45 @@
1
+ import { SCHEMA } from "./generated/wire.js";
2
+ /**
3
+ * One event as a site sends it (`contracts/vectors/ingest`), and the only way a site builds
4
+ * one: `pii` is what a person typed, kept apart — the panel seals it — and left out when
5
+ * empty.
6
+ */
7
+ export function siteEvent(key, head, properties, pii = {}) {
8
+ const [type, version] = key.split("@");
9
+ return {
10
+ id: head.id,
11
+ schema: SCHEMA,
12
+ type,
13
+ typeVersion: Number(version),
14
+ occurredAt: head.occurredAt.toISOString(),
15
+ source: { kind: "site", id: head.sourceId },
16
+ subject: head.subject,
17
+ properties,
18
+ ...(Object.keys(pii).length > 0 ? { pii } : {}),
19
+ };
20
+ }
21
+ /** `POST /api/ingest/v1/events`' body. */
22
+ export const ingestBody = (...events) => ({ events });
23
+ /** An RFC 9562 UUIDv7 for `atMs`, its other 74 bits random. */
24
+ export function uuidV7(atMs) {
25
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
26
+ let ms = atMs;
27
+ for (let i = 5; i >= 0; i--) {
28
+ bytes[i] = ms % 256;
29
+ ms = Math.floor(ms / 256);
30
+ }
31
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x70;
32
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
33
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
34
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
35
+ }
36
+ /**
37
+ * An id the panel takes for a location, a lead or a job: 1–128 of `[A-Za-z0-9._:-]`, starting
38
+ * alphanumeric — and not made only of digits, dots and dashes with seven digits or more, which
39
+ * is what a phone number looks like: an id sits in the clear.
40
+ */
41
+ export function isOpaqueId(s) {
42
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(s))
43
+ return false;
44
+ return !(/^[0-9.-]+$/.test(s) && (s.match(/[0-9]/g)?.length ?? 0) >= 7);
45
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A phone number as the funnel reads it: E.164 when the typed value is a
3
+ * number someone can be reached on, otherwise `null`.
4
+ *
5
+ * Only the French national plan is read closely (every brand of the vertical
6
+ * is in France): ten digits from `0[1-9]` — `06 12 34 56 78`, `+33 (0)6 …`,
7
+ * `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
8
+ * become `+33612345678`. Any other international number is a plausible E.164,
9
+ * 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
10
+ * `+447911123456`) — and, for the plans a visitor here most often dials
11
+ * from (`NATIONAL_LENGTH`), as long as that plan's numbers are, so `+12345678`
12
+ * is not a number. Full-width digits read as digits; a run of one digit
13
+ * (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
14
+ */
15
+ export declare function normalizePhone(raw: string): string | null;
16
+ /**
17
+ * What is wrong with a typed number, or `null`: `required` when nothing was
18
+ * typed, `invalid` when it is not a number we can call. The form blocks on it
19
+ * in the page's words and the server refuses on it — one rule, so a number
20
+ * the form lets through is never refused behind the visitor's back.
21
+ */
22
+ export declare function phoneProblem(raw: string): "required" | "invalid" | null;