@sphoro/bugcatch-web-sdk 0.13.1 → 0.14.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/src/index.js CHANGED
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
6
6
  import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
7
7
  import {
8
8
  watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
9
- clearToken, sendMessage, callAvailable, callKindForProject, requestCall,
9
+ clearToken, sendMessage, verifyFix, callKindsForProject,
10
10
  reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
11
11
  answerIncomingCall, declineIncomingCall,
12
12
  readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
@@ -16,9 +16,9 @@ import {
16
16
  import {
17
17
  loginState, startSignIn, confirmSignIn, endSignIn, readSession, clearSession,
18
18
  } from './session.js';
19
- import { startVoiceCall } from './voice.js';
20
19
  import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
21
20
  import { showIncomingCall } from './incoming-call.js';
21
+ import { referenceFields } from './references.js';
22
22
 
23
23
  // Replaced at build time from package.json. The guard is for anyone importing
24
24
  // src/ directly, where the define never runs.
@@ -63,16 +63,14 @@ const DEFAULTS = {
63
63
  // reports moves. Runs whether or not the built-in notification is on; `id` is
64
64
  // what markUpdateRead() takes.
65
65
  onUpdate: null,
66
- // Offer a voice call when a report is picked up, if the workspace has
66
+ // Offer a call to the team when a report is picked up, if the workspace has
67
67
  // sphoro.voice configured. Nothing is dialled and no permission is asked for
68
68
  // until somebody presses the button — see startCall().
69
69
  voice: true,
70
- // Called as a call moves: 'connecting', 'listening', 'speaking', 'ended',
71
- // 'failed'. The SDK shows nothing of its own beyond the offer, so an
72
- // integration that wants a call UI builds it from this and onCallTranscript.
70
+ // Called as a call moves: 'ringing', 'connecting', 'connected', 'missed',
71
+ // 'ended', 'failed'. The SDK shows nothing of its own beyond the offer, so an
72
+ // integration that wants a call UI builds it from this.
73
73
  onCallState: null,
74
- // Called with { role, text, final } for each line, as it is recognised.
75
- onCallTranscript: null,
76
74
  // Called with { bugId, callId, from, project, reference, ringsUntil,
77
75
  // recorded } when somebody on the team rings this page about a report filed
78
76
  // from it. The SDK has already drawn the card by then — this is for a host
@@ -324,6 +322,11 @@ class BugCatchClient {
324
322
  *
325
323
  * Resolves to null when this browser cannot prove it filed that report.
326
324
  */
325
+ async verifyFix(bugId, { result, resolvedAt, note }) {
326
+ const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
327
+ return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
328
+ }
329
+
327
330
  myReport(bugId) {
328
331
  return fetchReport({ apiBase: this.config.apiBase, bugId });
329
332
  }
@@ -349,11 +352,11 @@ class BugCatchClient {
349
352
  * Everything a report carries, however it was started.
350
353
  *
351
354
  * Shared by `report()` and by pressing Call, because the two file the same
352
- * thing — the difference is only whether a person typed the title or the
353
- * agent asks for it. Two copies of this would drift, and the half that
354
- * drifted would be the one that stopped attaching the console.
355
+ * thing — the difference is only whether a person typed a title first. Two
356
+ * copies of this would drift, and the half that drifted would be the one that
357
+ * stopped attaching the console.
355
358
  */
356
- async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [] }) {
359
+ async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [] }) {
357
360
  return {
358
361
  projectId: this.config.projectId,
359
362
  ...(title ? { title } : {}),
@@ -366,6 +369,9 @@ class BugCatchClient {
366
369
  reporterName: this.config.user?.name,
367
370
  metadata: {
368
371
  ...(await collectEnvironment()),
372
+ release: this.config.release,
373
+ environment: this.config.environment,
374
+ buildId: this.config.buildId,
369
375
  ...metadata,
370
376
  user: this.config.user || undefined,
371
377
  sdk: SDK_IDENTITY,
@@ -377,18 +383,23 @@ class BugCatchClient {
377
383
  // Omitted rather than sent empty: the field is optional server-side, and
378
384
  // an empty array in every payload makes beforeSend hooks harder to read.
379
385
  ...(media.length ? { attachments: media } : {}),
386
+ // What the reporter answered when asked which record this is about. Only
387
+ // ever what they typed into the box — never what this browser matched in
388
+ // their own text, which the API reads for itself out of the title and
389
+ // description it is being sent anyway. A payload is a claim, and there is
390
+ // no reason to make the join key one when the source text is right here.
391
+ ...(references.length ? { references } : {}),
380
392
  };
381
393
  }
382
394
 
383
395
  /**
384
396
  * File a report because somebody pressed Call instead of filling the form in.
385
397
  *
386
- * Both kinds of call come through here — the triage agent and ringing the
387
- * team — because filing is the half the two share, and it is the half that
388
- * has to say who is filing. Held in one place after a session header went
389
- * missing from this route while `report()` kept sending it: nothing looked
390
- * wrong until a project switched sign-in on, and then a signed-in reporter
391
- * pressing Call was told to sign in.
398
+ * Filing is the half a call shares with the form, and it is the half that has
399
+ * to say who is filing. Held in one place after a session header went missing
400
+ * from this route while `report()` kept sending it: nothing looked wrong until
401
+ * a project switched sign-in on, and then a signed-in reporter pressing Call
402
+ * was told to sign in.
392
403
  */
393
404
  async #fileByCall() {
394
405
  const session = readSession(this.config.projectId);
@@ -426,25 +437,23 @@ class BugCatchClient {
426
437
  * Two conditions, and both have to hold before anything is drawn. The project
427
438
  * has to have calling configured — false on most deployments — and this
428
439
  * browser has to know who the visitor is, because a call with nobody to
429
- * attribute it to has no watch token to follow it with and nobody to send the
430
- * transcript to afterwards. That is the same limit every other follow-up
431
- * feature in this SDK has: it all hangs off `identify()`.
440
+ * attribute it to has no watch token to follow it with and nobody to tell
441
+ * afterwards what happened to the report. That is the same limit every other
442
+ * follow-up feature in this SDK has: it all hangs off `identify()`.
432
443
  */
433
444
  async canCallNow() {
434
445
  return Boolean(await this.callKind());
435
446
  }
436
447
 
437
448
  /**
438
- * Which kind of call this visitor would get: 'agent', 'live', or null.
449
+ * Which kind of call this visitor would get: 'live', or null for none.
439
450
  *
440
451
  * const kind = await BugCatch.callKind();
441
- * // 'agent' — a model answers and asks the questions the form did not
442
- * // 'live' — the team's dashboards ring and a colleague picks up
452
+ * // 'live' — the team's dashboards ring and a colleague picks up
443
453
  *
444
- * A deployment offers one or the other, never both: a sphoro.voice agent has
445
- * exactly one mode. Worth asking rather than assuming, because the two are
446
- * wholly different promises to make to somebody — and the wording next to the
447
- * button has to match which one it is.
454
+ * One kind, and this says whether it is on offer. It is still worth asking
455
+ * rather than assuming: on most deployments the answer is null, and a button
456
+ * promising a call nobody can place is worse than no button.
448
457
  *
449
458
  * The answer is remembered for the life of the page. It is a property of the
450
459
  * deployment's configuration rather than of this visitor, and re-asking on
@@ -452,15 +461,36 @@ class BugCatchClient {
452
461
  * about once a year.
453
462
  */
454
463
  async callKind() {
455
- if (!this.config.voice) return null;
456
- if (!this.config.user?.email) return null;
457
- if (this.kind !== undefined) return this.kind;
458
- this.kind = await callKindForProject({
464
+ const kinds = await this.callKinds();
465
+ return kinds.live ? 'live' : null;
466
+ }
467
+
468
+ /**
469
+ * The same answer as booleans: `{ agent, live }`.
470
+ *
471
+ * const { live } = await BugCatch.callKinds();
472
+ * if (live) drawCallTheTeam();
473
+ *
474
+ * `agent` is always false. It was a triage agent that answered instantly
475
+ * instead of a person, it is gone, and the key stays only so an integration
476
+ * that reads it does not get `undefined` and draw a button on it.
477
+ *
478
+ * Same round trip and same page-lifetime cache as `callKind()`. False on a
479
+ * deployment without sphoro.voice, on a workspace that has switched calling
480
+ * off, and on a visitor this SDK has no address for — a call needs somebody
481
+ * to attribute it to.
482
+ */
483
+ async callKinds() {
484
+ const none = { agent: false, live: false };
485
+ if (!this.config.voice) return none;
486
+ if (!this.config.user?.email) return none;
487
+ if (this.kinds !== undefined) return this.kinds;
488
+ this.kinds = await callKindsForProject({
459
489
  apiBase: this.config.apiBase,
460
490
  apiKey: this.config.apiKey,
461
491
  projectId: this.config.projectId,
462
492
  });
463
- return this.kind;
493
+ return this.kinds;
464
494
  }
465
495
 
466
496
  /**
@@ -472,174 +502,98 @@ class BugCatchClient {
472
502
  * workspace that has not switched calling on, and on a report that has had
473
503
  * its allowance of calls — so an integration rendering its own affordance can
474
504
  * ask rather than guess.
505
+ *
506
+ * The same question as `canCallTeam`, which is the name the widget uses. Both
507
+ * are kept because both are documented, and there is one kind of call left
508
+ * for either of them to be asking about.
475
509
  */
476
510
  canCall(bugId) {
477
511
  if (!this.config.voice) return Promise.resolve(false);
478
- return callAvailable({ apiBase: this.config.apiBase, bugId });
512
+ return liveCallAvailable({ apiBase: this.config.apiBase, bugId });
479
513
  }
480
514
 
481
515
  /**
482
- * Talk to the triage agent.
516
+ * Ring the team.
483
517
  *
484
518
  * await BugCatch.startCall(id); // about a report already filed
485
519
  * await BugCatch.startCall(); // instead of filling the form in
486
520
  *
487
- * With a report id, it talks about that report. With none, it files one first
521
+ * With a report id, it rings about that report. With none, it files one first
488
522
  * — carrying the console, network and environment this SDK is already holding
489
- * — and then calls about that. The report is written before the call starts
490
- * either way, so a call that drops still leaves the context behind; the
491
- * handle carries `bugId` so the caller can say what it was filed as.
523
+ * — and rings about that. The report is written before the phones ring either
524
+ * way, so a call nobody answers still leaves the context behind; the handle
525
+ * carries `bugId` so the caller can say what it was filed as.
492
526
  *
493
- * Asks for the microphone, opens the call, and resolves once it is connected.
494
- * What is said reaches the team as a message in the report's thread when the
495
- * call ends — nobody from the team is on the line, and the agent says so.
527
+ * Asks for the microphone, places the call, and resolves once it is
528
+ * *ringing* — not once somebody answers. The handle's `onState` is how the
529
+ * caller follows what happens after that:
530
+ *
531
+ * 'ringing' the team's dashboards are lighting up
532
+ * 'connecting' somebody answered; the two browsers are finding each other
533
+ * 'connected' they can hear each other
534
+ * 'missed' nobody picked up before the ringing stopped
535
+ * 'ended' it is over
536
+ *
537
+ * What comes back is a conversation with a colleague: the audio travels
538
+ * directly between the two browsers over WebRTC and never reaches bugcatch or
539
+ * sphoro.voice, so it is not transcribed. The thread gets a note saying the
540
+ * call happened and who took it.
496
541
  *
497
542
  * Rejects if the microphone is refused, if calling is not available, or if
498
543
  * one is already running. There is deliberately no way to start a second: two
499
- * microphones on one page talk over each other, and the second call would be
500
- * transcribing the first. A rejection carrying `.filed` means the report was
501
- * written and only the call failed.
544
+ * microphones on one page talk over each other. A rejection carrying `.filed`
545
+ * means the report was written and only the call failed.
502
546
  */
503
547
  async startCall(bugId) {
504
548
  if (!this.config.voice) throw new Error('[BugCatch] calling is switched off.');
505
549
  // Two checks, because there are two ways to already be on a call: one that
506
550
  // has connected, and one still asking for the microphone. A double-click
507
551
  // races past a check on `this.call` alone — the second press gets through
508
- // while the first is still awaiting, and the page ends up with two sockets
509
- // and two microphones transcribing each other.
552
+ // while the first is still awaiting, and the page ends up with two
553
+ // microphones in one conversation.
510
554
  if (this.call || this.callStarting) {
511
555
  throw new Error('[BugCatch] a call is already running.');
512
556
  }
513
557
  this.callStarting = true;
514
558
 
515
559
  try {
516
- return await this.#connectCall(bugId);
560
+ // Whether this deployment can place a call at all, asked before the
561
+ // microphone prompt and before anything is filed. Without it a press from
562
+ // a visitor with no address, or on a project with calling off, filed a
563
+ // report anyway — and the reporter, promised a call, got a ticket and a
564
+ // phone that never rang.
565
+ if (!await this.callKind()) {
566
+ throw new Error(this.config.user?.email
567
+ ? '[BugCatch] calling is not available on this project.'
568
+ : '[BugCatch] a call needs to know who is calling — call identify() with an email first.');
569
+ }
570
+ return await this.#ringTheTeam(bugId);
517
571
  } finally {
518
572
  this.callStarting = false;
519
573
  }
520
574
  }
521
575
 
522
- /** The body of startCall, so the in-flight flag is cleared on every path. */
523
- async #connectCall(bugId) {
524
- // Which kind of call this deployment actually offers, asked before anything
525
- // is filed. `startCall` is the one entry point most integrations use — the
526
- // widget's own button included — so it has to land on whichever kind the
527
- // agent is, rather than on the one this method was originally written for.
528
- //
529
- // Without this the agent path ran against a person-to-person agent: the call
530
- // came back *ringing*, the API refused it rather than connect a reporter to a
531
- // rendezvous over the µ-law agent protocol, and all the reporter saw was a
532
- // report they had not meant to file.
533
- if (await this.callKind() === 'live') return this.#ringTheTeam(bugId);
534
-
535
- let realtimeUrl;
536
- let filed = null;
537
-
538
- if (bugId) {
539
- ({ realtime_url: realtimeUrl } = await requestCall({
540
- apiBase: this.config.apiBase, bugId,
541
- }));
542
- } else {
543
- // No report yet: this is somebody who pressed Call instead of filling the
544
- // form in. One round trip files what the page was doing and starts the
545
- // call about it, so a call that never connects still leaves the context
546
- // behind.
547
- filed = await this.#fileByCall();
548
- bugId = filed.id;
549
- realtimeUrl = filed.realtime_url;
550
- if (!realtimeUrl) {
551
- // Filed, but no call. Reported as such rather than thrown: the report
552
- // exists and the caller has to be able to say so.
553
- const err = new Error('[BugCatch] the report was filed, but no call could be started.');
554
- err.filed = filed;
555
- throw err;
556
- }
557
- }
558
-
559
- const handle = await startVoiceCall({
560
- realtimeUrl,
561
- onState: (state, detail) => {
562
- if (state === 'ended' || state === 'failed') this.call = null;
563
- if (typeof this.config.onCallState === 'function') {
564
- this.config.onCallState(state, { bugId, ...detail });
565
- }
566
- this.emit('call', { bugId, state, ...detail });
567
- },
568
- onTranscript: (line) => {
569
- if (typeof this.config.onCallTranscript === 'function') {
570
- this.config.onCallTranscript({ bugId, ...line });
571
- }
572
- this.emit('call-transcript', { bugId, ...line });
573
- },
574
- });
575
-
576
- // Set after the await, not before: a call that never connected is not one
577
- // this page is on, and leaving the slot taken would block the retry.
578
- this.call = handle;
579
- // `bugId` on the handle so a caller that did not name one — the Call
580
- // button — can tell the reporter what their report is called.
581
- return Object.assign(handle, { bugId, filed });
582
- }
583
-
584
576
  /**
585
- * Talk to a person on the team.
577
+ * Ring the team, under the name an integration may already be calling.
586
578
  *
587
579
  * await BugCatch.startLiveCall(id);
588
580
  *
589
- * The other kind of call. `startCall` reaches the triage agent, which answers
590
- * instantly and asks the three questions the form did not; this rings the
591
- * team, and whoever is free picks it up. What comes back is a conversation
592
- * with a colleague — the audio travels directly between the two browsers over
593
- * WebRTC and never reaches bugcatch or sphoro.voice, so it is not recorded and
594
- * not transcribed. The thread gets a note saying it happened and who took it.
595
- *
596
- * Resolves once the call is *ringing*, not once somebody answers — the
597
- * handle's `onState` is how the caller follows what happens next:
598
- *
599
- * 'ringing' the team's dashboards are lighting up
600
- * 'connecting' somebody answered; the two browsers are finding each other
601
- * 'connected' they can hear each other
602
- * 'missed' nobody picked up before the ringing stopped
603
- * 'ended' it is over
604
- *
605
- * A report id is required, unlike `startCall`. Ringing a room about a report
606
- * nobody can read would ask somebody to answer a call they know nothing
607
- * about — and the agent call's trick of filing first and taking a title from
608
- * the transcript is not available here, because there is no transcript.
609
- *
610
- * Rejects if the microphone is refused, if live calling is not available on
611
- * this report, or if a call of either kind is already running: this page has
612
- * one microphone, and two calls on it would talk over each other.
581
+ * Exactly `startCall`, which is now the only call there is. The id is
582
+ * optional here too: it was required while this was one of two kinds, because
583
+ * the other one was the only way to call without a report to call about.
613
584
  */
614
- async startLiveCall(bugId) {
615
- if (!this.config.voice) throw new Error('[BugCatch] calling is switched off.');
616
- if (!bugId) throw new Error('[BugCatch] startLiveCall needs the id of a filed report.');
617
- if (!liveCallSupported()) {
618
- throw new Error('[BugCatch] this browser cannot make live calls.');
619
- }
620
- // The same two checks `startCall` makes, against the same slot, because the
621
- // two kinds of call compete for the same microphone — a live call started
622
- // while the agent is mid-sentence would be two conversations in one room.
623
- if (this.call || this.callStarting) {
624
- throw new Error('[BugCatch] a call is already running.');
625
- }
626
- this.callStarting = true;
627
-
628
- try {
629
- return await this.#ringTheTeam(bugId);
630
- } finally {
631
- this.callStarting = false;
632
- }
585
+ startLiveCall(bugId) {
586
+ return this.startCall(bugId);
633
587
  }
634
588
 
635
- /** The body of startLiveCall, so the in-flight flag clears on every path. */
589
+ /** The body of startCall, so the in-flight flag clears on every path. */
636
590
  async #ringTheTeam(bugId) {
637
591
  // Before the call is placed, not after. A permission prompt takes as long
638
592
  // as the person takes to read it, and asking afterwards would have a room
639
593
  // full of dashboards ringing about a call its own caller may yet refuse to
640
594
  // be on. That ordering is why the report below is filed *after* the
641
- // microphone too, even though filing it first is what the agent path does:
642
- // on this kind of call, filing is what makes the phones ring.
595
+ // microphone too, rather than first as the form does: filing is what makes
596
+ // the phones ring.
643
597
  if (!liveCallSupported()) {
644
598
  throw new Error('[BugCatch] this browser cannot make live calls.');
645
599
  }
@@ -652,16 +606,18 @@ class BugCatchClient {
652
606
  started = await requestLiveCall({ apiBase: this.config.apiBase, bugId });
653
607
  } else {
654
608
  // Nobody filled the form in. One round trip writes what the page was
655
- // doing and rings the team about it — the same trade the agent path
656
- // makes, so a call nobody answers still leaves the context behind.
609
+ // doing and rings the team about it, so a call nobody answers still
610
+ // leaves the context behind.
657
611
  filed = await this.#fileByCall();
658
612
  bugId = filed.id;
659
613
  started = filed.live_call;
660
614
  if (!started?.realtime_url) {
661
615
  // Filed, but nobody rang. Reported as such rather than thrown, so the
662
616
  // caller can say what the report is called instead of losing it.
663
- const err = new Error('[BugCatch] the report was filed, but the team could not be rung.');
617
+ const err = new Error(`[BugCatch] the report was filed, but the team could not be rung${
618
+ filed.call_error ? `: ${filed.call_error}` : '.'}`);
664
619
  err.filed = filed;
620
+ err.reason = filed.call_error || null;
665
621
  throw err;
666
622
  }
667
623
  }
@@ -689,9 +645,8 @@ class BugCatchClient {
689
645
  },
690
646
  });
691
647
 
692
- // Set after the await, exactly as the agent path does: a call that never
693
- // connected is not one this page is on, and leaving the slot taken would
694
- // block the retry.
648
+ // Set after the await, not before: a call that never connected is not one
649
+ // this page is on, and leaving the slot taken would block the retry.
695
650
  this.call = handle;
696
651
  return Object.assign(handle, { bugId, callId: started.call_id, live: true, filed });
697
652
  }
@@ -935,6 +890,7 @@ class BugCatchClient {
935
890
  /** File a bug with everything captured so far. */
936
891
  async report({
937
892
  title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
893
+ references = [],
938
894
  } = {}) {
939
895
  if (!this.started) throw new Error('[BugCatch] call init() first.');
940
896
  if (!title || !title.trim()) throw new Error('[BugCatch] a title is required.');
@@ -952,7 +908,9 @@ class BugCatchClient {
952
908
  mime: normalizeMime(a.mime, REPORT_MIMES),
953
909
  }));
954
910
 
955
- let payload = await this.#payload({ title: title.trim(), description, priority, metadata, media });
911
+ let payload = await this.#payload({
912
+ title: title.trim(), description, priority, metadata, media, references,
913
+ });
956
914
 
957
915
  if (typeof this.config.beforeSend === 'function') {
958
916
  const result = this.config.beforeSend(payload);
@@ -1027,6 +985,31 @@ class BugCatchClient {
1027
985
  });
1028
986
  }
1029
987
 
988
+ /**
989
+ * What this project needs in order to look a report up, and how it asks.
990
+ *
991
+ * const { policy, fields } = await BugCatch.referenceFields();
992
+ * // policy: 'off' | 'optional' | 'required'
993
+ *
994
+ * The widget calls this itself when it opens, and caches the answer for the
995
+ * life of the page: unlike the sign-in state, this cannot change underneath a
996
+ * reporter in a way that matters — an admin editing the field list mid-report
997
+ * costs one stale question, not a failed send.
998
+ *
999
+ * Here for the same reason loginState() is: an integration running with
1000
+ * `widget: false` draws its own form and needs the same answer to know
1001
+ * whether that form should have an identifier box on it.
1002
+ */
1003
+ referenceFields() {
1004
+ if (this.refFields) return this.refFields;
1005
+ this.refFields = referenceFields({
1006
+ apiBase: this.config.apiBase,
1007
+ apiKey: this.config.apiKey,
1008
+ projectId: this.config.projectId,
1009
+ });
1010
+ return this.refFields;
1011
+ }
1012
+
1030
1013
  /**
1031
1014
  * Who this browser is signed in as on this project, from storage alone.
1032
1015
  *
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Reading the reporter's own text for something that identifies their record.
3
+ *
4
+ * A browser-side twin of bugcatch-api/src/settings/reference-extract.ts, and
5
+ * deliberately only the half this side needs. The server does the extraction
6
+ * that counts — what a payload claims it found is not evidence, and the value
7
+ * an investigation eventually joins on is the one the API read out of the text
8
+ * it stored. This copy exists to answer one question, locally and instantly:
9
+ *
10
+ * does what this person has already typed contain an identifier, or do we
11
+ * have to interrupt them for one?
12
+ *
13
+ * Getting that wrong in the lenient direction costs an unnecessary question.
14
+ * Getting it wrong in the strict direction means asking a reporter who has
15
+ * already told us, in the sentence above the box, which is the fastest way to
16
+ * make somebody close a bug reporter and not come back. So the matching rule
17
+ * here is the same rule, run over the same patterns, which the API ships with
18
+ * the field list precisely so that both sides can agree.
19
+ *
20
+ * The patterns arrive over the wire and are compiled here. That is a thing to
21
+ * be deliberate about rather than nervous about: they are shapes, not secrets —
22
+ * "a loan number looks like LN- followed by digits" is not a fact worth hiding
23
+ * from somebody already looking at their own loan number — and the alternative
24
+ * is a round trip per keystroke or a question for everybody.
25
+ */
26
+
27
+ const MAX_SCAN_CHARS = 20000;
28
+ const MAX_VALUE_CHARS = 120;
29
+ const MAX_MATCHES = 200;
30
+
31
+ /**
32
+ * Compile, or null.
33
+ *
34
+ * Null on anything that will not compile, which in a browser is a real case
35
+ * rather than a defensive one: a regex feature one engine lacks throws here
36
+ * rather than failing to match. A field that cannot be compiled is simply not
37
+ * scanned, the reporter gets asked, and the server — which compiled it fine —
38
+ * still reads the text. Degrading to the question is the whole design.
39
+ */
40
+ function compile(source) {
41
+ if (typeof source !== 'string' || !source.trim() || source.length > 400) return null;
42
+ try {
43
+ return new RegExp(source.trim(), 'gi');
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ const tidy = (value) => String(value).replace(/\s+/g, ' ').trim().slice(0, MAX_VALUE_CHARS);
50
+
51
+ /** Group 1 where the pattern has one, the whole match otherwise. */
52
+ function valueOf(match) {
53
+ const raw = match.length > 1 && match[1] !== undefined ? match[1] : match[0];
54
+ if (typeof raw !== 'string' || !raw.trim()) return null;
55
+ // The span claimed is the whole match, never the capture: a labelled rule has
56
+ // eaten the words in front of the value too, and leaving them unclaimed lets
57
+ // a later field match them again.
58
+ return { value: tidy(raw), start: match.index, end: match.index + match[0].length };
59
+ }
60
+
61
+ /**
62
+ * Every configured identifier found in the given text.
63
+ *
64
+ * Fields are taken in the order the project arranged them and a span an earlier
65
+ * field claimed is not offered to a later one — which is what keeps the rule for
66
+ * "alternate number" and the rule for "mobile number", which match the same ten
67
+ * digits, from both claiming it.
68
+ */
69
+ export function matchReferences(fields, texts) {
70
+ const found = [];
71
+ const seen = new Set();
72
+
73
+ for (const block of texts || []) {
74
+ const text = typeof block === 'string' ? block.slice(0, MAX_SCAN_CHARS) : '';
75
+ if (!text) continue;
76
+
77
+ const claimed = [];
78
+ const overlaps = (a, b) => claimed.some(([s, e]) => a < e && b > s);
79
+
80
+ for (const field of fields || []) {
81
+ if (!field || field.scan === false) continue;
82
+ const re = compile(field.pattern);
83
+ if (!re) continue;
84
+
85
+ let match;
86
+ let guard = 0;
87
+ re.lastIndex = 0;
88
+ while ((match = re.exec(text)) !== null) {
89
+ if (++guard > MAX_MATCHES) break;
90
+ // exec does not advance past a zero-width match, so this would spin.
91
+ if (match[0] === '') { re.lastIndex += 1; continue; }
92
+
93
+ const hit = valueOf(match);
94
+ if (hit && !overlaps(hit.start, hit.end)) {
95
+ claimed.push([hit.start, hit.end]);
96
+ const dedupe = `${field.key}:${hit.value.toLowerCase()}`;
97
+ if (!seen.has(dedupe)) {
98
+ seen.add(dedupe);
99
+ found.push({ key: field.key, label: field.label, value: hit.value });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ return found;
107
+ }
108
+
109
+ /**
110
+ * Whether what somebody typed into the box is the shape that field asks for.
111
+ *
112
+ * Anchored, unlike the scan above: reading prose means finding a value inside a
113
+ * sentence, and checking an answer means the answer *is* the value. `valuePattern`
114
+ * where the field has one — a rule that finds "alternate number: 98…" in prose
115
+ * needs the words, and a box already headed "Alternate number" will not have
116
+ * them typed into it again.
117
+ *
118
+ * Returns the tidied value, or null. Advisory only: the widget uses it to say
119
+ * "that does not look like a loan number" before sending, and the API keeps a
120
+ * value that fails it rather than dropping what the reporter believes is their
121
+ * reference.
122
+ */
123
+ export function validateReference(field, raw) {
124
+ const value = typeof raw === 'string' ? raw.trim() : '';
125
+ if (!value || value.length > MAX_VALUE_CHARS) return null;
126
+
127
+ const re = compile(field && (field.valuePattern || field.pattern));
128
+ if (!re) return null;
129
+
130
+ let match;
131
+ let guard = 0;
132
+ re.lastIndex = 0;
133
+ while ((match = re.exec(value)) !== null) {
134
+ if (++guard > MAX_MATCHES) break;
135
+ if (match[0] === '') { re.lastIndex += 1; continue; }
136
+ // One character of slack at the front and only there, for a pattern whose
137
+ // left boundary consumes rather than looks behind — see the phone rule,
138
+ // written that way so it compiles in older Safari.
139
+ const toEnd = match.index + match[0].length === value.length;
140
+ const atEdge = match.index === 0 || match.index === 1;
141
+ if (toEnd && atEdge) {
142
+ const hit = valueOf(match);
143
+ if (hit) return hit.value;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * What this project needs in order to look a report up.
151
+ *
152
+ * Every failure answers `{ policy: 'off', fields: [] }`, which is the answer
153
+ * that draws nothing. A widget that could not reach this route must not be a
154
+ * widget that refuses to send a report — the whole feature is a help, and the
155
+ * server reads the text either way.
156
+ */
157
+ export async function referenceFields({ apiBase, apiKey, projectId }) {
158
+ const off = { policy: 'off', fields: [] };
159
+ try {
160
+ const res = await fetch(
161
+ `${apiBase}/ingest/reference-fields?projectId=${encodeURIComponent(projectId)}`,
162
+ { headers: { 'X-BugCatch-Key': apiKey } },
163
+ );
164
+ if (!res.ok) return off;
165
+ const body = await res.json();
166
+ const policy = body.policy === 'optional' || body.policy === 'required' ? body.policy : 'off';
167
+ const fields = Array.isArray(body.fields) ? body.fields : [];
168
+ return fields.length ? { policy, fields } : off;
169
+ } catch {
170
+ return off;
171
+ }
172
+ }