@go-avro/avro-js 0.0.25 → 0.0.27

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.
@@ -7,6 +7,16 @@ import { CancelToken, RetryStrategy } from '../types/client';
7
7
  import { StandardError } from '../types/error';
8
8
  import { CacheData } from '../types/cache';
9
9
  import { Waiver } from '../types/api/Waiver';
10
+ import type { EmailSucceededPayload, EmailFailedPayload, EmailType } from "../types/api/EmailNotification";
11
+ /** Callbacks for a tracked email request. */
12
+ export interface TrackEmailOptions {
13
+ emailType?: EmailType;
14
+ /** How long to wait before firing onTimeout (ms). Default 30 000. */
15
+ timeout?: number;
16
+ onSuccess?: (data: EmailSucceededPayload) => void;
17
+ onFailure?: (data: EmailFailedPayload) => void;
18
+ onTimeout?: (requestId: string) => void;
19
+ }
10
20
  export interface AvroQueryClientConfig {
11
21
  baseUrl: string;
12
22
  authManager: AuthManager;
@@ -130,6 +140,7 @@ declare module '../client/QueryClient' {
130
140
  billId: string;
131
141
  subject?: string;
132
142
  recipients?: string[][];
143
+ request_id?: string;
133
144
  }): Promise<{
134
145
  msg: string;
135
146
  }>;
@@ -515,6 +526,8 @@ export declare class AvroQueryClient {
515
526
  private authStateListeners;
516
527
  private _queryClient;
517
528
  private _socketInvalidationCleanup;
529
+ private _emailTracking;
530
+ private _emailListenersInit;
518
531
  constructor(config: AvroQueryClientConfig);
519
532
  emit(eventName: string, data: unknown): void;
520
533
  on<T>(eventName: string, callback: (data: T) => void): void;
@@ -708,10 +721,26 @@ export declare class AvroQueryClient {
708
721
  query?: string;
709
722
  offset?: number;
710
723
  }, cancelToken?: CancelToken, headers?: Record<string, string>): Promise<any>;
724
+ /**
725
+ * Lazily register socket listeners for email_succeeded / email_failed.
726
+ * Listeners live on the socket (not in a React effect) so they survive
727
+ * component unmounts.
728
+ */
729
+ private _initEmailListeners;
730
+ /**
731
+ * Track an outbound email request.
732
+ *
733
+ * Generates a `request_id`, registers socket listeners (once), and returns
734
+ * the ID so callers can pass it in the HTTP body. The backend emits
735
+ * `email_succeeded` / `email_failed` to the user's GUID room; this method
736
+ * correlates the event by `request_id` and fires the appropriate callback.
737
+ */
738
+ trackEmail(options?: TrackEmailOptions): string;
711
739
  sendEmail(emailId: string, formData: FormData, progressUpdateCallback?: (loaded: number, total: number) => void): Promise<void>;
712
740
  sendBillEmail(billId: string, body?: {
713
741
  subject?: string;
714
742
  recipients?: string[][];
715
743
  base_url?: string;
744
+ request_id?: string;
716
745
  }): Promise<void>;
717
746
  }
@@ -1,5 +1,6 @@
1
1
  import io from 'socket.io-client';
2
2
  import { useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import { v4 as uuidv4 } from "uuid";
3
4
  import { LoginResponse } from '../types/api';
4
5
  import { AuthState } from '../types/auth';
5
6
  import { StandardError } from '../types/error';
@@ -88,6 +89,8 @@ export class AvroQueryClient {
88
89
  this.authStateListeners = [];
89
90
  this._queryClient = null;
90
91
  this._socketInvalidationCleanup = null;
92
+ this._emailTracking = new Map();
93
+ this._emailListenersInit = false;
91
94
  this.config = {
92
95
  baseUrl: config.baseUrl,
93
96
  authManager: config.authManager,
@@ -685,6 +688,63 @@ export class AvroQueryClient {
685
688
  throw new StandardError(500, 'Failed to fetch sessions');
686
689
  });
687
690
  }
691
+ /* ── Email delivery tracking ──────────────────────────────────────── */
692
+ /**
693
+ * Lazily register socket listeners for email_succeeded / email_failed.
694
+ * Listeners live on the socket (not in a React effect) so they survive
695
+ * component unmounts.
696
+ */
697
+ _initEmailListeners() {
698
+ if (this._emailListenersInit)
699
+ return;
700
+ this._emailListenersInit = true;
701
+ this.socket.on("email_succeeded", (data) => {
702
+ const entry = this._emailTracking.get(data.request_id);
703
+ if (!entry)
704
+ return;
705
+ // Reset timeout — more recipients may follow for the same request_id
706
+ clearTimeout(entry.timerId);
707
+ entry.timerId = setTimeout(() => {
708
+ this._emailTracking.delete(data.request_id);
709
+ }, 5000);
710
+ entry.onSuccess?.(data);
711
+ });
712
+ this.socket.on("email_failed", (data) => {
713
+ const entry = this._emailTracking.get(data.request_id);
714
+ if (!entry)
715
+ return;
716
+ clearTimeout(entry.timerId);
717
+ entry.timerId = setTimeout(() => {
718
+ this._emailTracking.delete(data.request_id);
719
+ }, 5000);
720
+ entry.onFailure?.(data);
721
+ });
722
+ }
723
+ /**
724
+ * Track an outbound email request.
725
+ *
726
+ * Generates a `request_id`, registers socket listeners (once), and returns
727
+ * the ID so callers can pass it in the HTTP body. The backend emits
728
+ * `email_succeeded` / `email_failed` to the user's GUID room; this method
729
+ * correlates the event by `request_id` and fires the appropriate callback.
730
+ */
731
+ trackEmail(options = {}) {
732
+ this._initEmailListeners();
733
+ const requestId = uuidv4();
734
+ const { timeout = 30000 } = options;
735
+ const timerId = setTimeout(() => {
736
+ this._emailTracking.delete(requestId);
737
+ options.onTimeout?.(requestId);
738
+ }, timeout);
739
+ this._emailTracking.set(requestId, {
740
+ emailType: options.emailType,
741
+ timerId,
742
+ onSuccess: options.onSuccess,
743
+ onFailure: options.onFailure,
744
+ onTimeout: options.onTimeout,
745
+ });
746
+ return requestId;
747
+ }
688
748
  sendEmail(emailId, formData, progressUpdateCallback) {
689
749
  try {
690
750
  return this.post({
@@ -193,10 +193,10 @@ AvroQueryClient.prototype.generatePDFFromBackend = function ({ billId }) {
193
193
  return response;
194
194
  });
195
195
  };
196
- AvroQueryClient.prototype.sendBillingEmail = async function ({ subject, billId, recipients, }) {
196
+ AvroQueryClient.prototype.sendBillingEmail = async function ({ subject, billId, recipients, request_id, }) {
197
197
  return this.post({
198
198
  path: `/bill/${billId}/email`,
199
- data: JSON.stringify({ recipients, subject }),
199
+ data: JSON.stringify({ recipients, subject, request_id }),
200
200
  headers: {
201
201
  "Content-Type": "application/json"
202
202
  }
@@ -0,0 +1,47 @@
1
+ import type { EmailFailedPayload, EmailType } from "../../types/api/EmailNotification";
2
+ export type EmailResultStatus = "succeeded" | "failed" | "timeout";
3
+ export interface EmailResult {
4
+ requestId: string;
5
+ status: EmailResultStatus;
6
+ emailType?: EmailType;
7
+ recipient?: string;
8
+ error?: EmailFailedPayload["error"];
9
+ }
10
+ export interface UseEmailStatusOptions {
11
+ /** How long to wait for a socket event before firing `onTimeout` (ms). Default 30 000. */
12
+ timeout?: number;
13
+ /** Called when the backend reports success. */
14
+ onSuccess?: (result: EmailResult) => void;
15
+ /** Called when the backend reports failure. */
16
+ onFailure?: (result: EmailResult) => void;
17
+ /** Called when neither success nor failure arrives within the timeout window. */
18
+ onTimeout?: (requestId: string) => void;
19
+ }
20
+ /**
21
+ * Subscribe to backend email delivery notifications via Socket.IO.
22
+ *
23
+ * Delegates to `AvroQueryClient.trackEmail()` which registers socket
24
+ * listeners on the client itself (not in a React effect), so
25
+ * notifications survive component unmounts.
26
+ *
27
+ * Usage:
28
+ * ```ts
29
+ * const { trackEmail, pending, results } = useEmailStatus({
30
+ * onSuccess: (r) => toast.success(`Email sent to ${r.recipient}`),
31
+ * onFailure: (r) => toast.error(`Email failed: ${r.error?.message}`),
32
+ * onTimeout: (id) => toast.warn("Email status unknown"),
33
+ * });
34
+ *
35
+ * const requestId = trackEmail("bill");
36
+ * await avroQueryClient.sendBillingEmail({ billId, request_id: requestId });
37
+ * ```
38
+ */
39
+ export declare function useEmailStatus(options?: UseEmailStatusOptions): {
40
+ readonly trackEmail: (emailType?: EmailType) => string;
41
+ readonly pending: Map<string, {
42
+ emailType?: EmailType;
43
+ trackedAt: number;
44
+ }>;
45
+ readonly results: Map<string, EmailResult>;
46
+ readonly clearResult: (requestId: string) => void;
47
+ };
@@ -0,0 +1,104 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { useAvroQueryClient } from "../../client/AvroQueryClientProvider";
3
+ /* ──────────────────────────────────────────────────────────────────────── */
4
+ /* Hook */
5
+ /* ──────────────────────────────────────────────────────────────────────── */
6
+ /**
7
+ * Subscribe to backend email delivery notifications via Socket.IO.
8
+ *
9
+ * Delegates to `AvroQueryClient.trackEmail()` which registers socket
10
+ * listeners on the client itself (not in a React effect), so
11
+ * notifications survive component unmounts.
12
+ *
13
+ * Usage:
14
+ * ```ts
15
+ * const { trackEmail, pending, results } = useEmailStatus({
16
+ * onSuccess: (r) => toast.success(`Email sent to ${r.recipient}`),
17
+ * onFailure: (r) => toast.error(`Email failed: ${r.error?.message}`),
18
+ * onTimeout: (id) => toast.warn("Email status unknown"),
19
+ * });
20
+ *
21
+ * const requestId = trackEmail("bill");
22
+ * await avroQueryClient.sendBillingEmail({ billId, request_id: requestId });
23
+ * ```
24
+ */
25
+ export function useEmailStatus(options = {}) {
26
+ const { timeout = 30000 } = options;
27
+ const client = useAvroQueryClient();
28
+ // Use refs for callbacks so the client-level handlers always see the latest
29
+ const onSuccessRef = useRef(options.onSuccess);
30
+ const onFailureRef = useRef(options.onFailure);
31
+ const onTimeoutRef = useRef(options.onTimeout);
32
+ onSuccessRef.current = options.onSuccess;
33
+ onFailureRef.current = options.onFailure;
34
+ onTimeoutRef.current = options.onTimeout;
35
+ // Expose reactive state so the UI can render pending / completed
36
+ const [pending, setPending] = useState(new Map());
37
+ const [results, setResults] = useState(new Map());
38
+ /* ── trackEmail — delegates to client.trackEmail() ──────────────── */
39
+ const trackEmail = useCallback((emailType) => {
40
+ const requestId = client.trackEmail({
41
+ emailType,
42
+ timeout,
43
+ onSuccess: (data) => {
44
+ const result = {
45
+ requestId: data.request_id,
46
+ status: "succeeded",
47
+ emailType: data.email_type,
48
+ recipient: data.recipient,
49
+ };
50
+ setPending((prev) => {
51
+ const next = new Map(prev);
52
+ next.delete(data.request_id);
53
+ return next;
54
+ });
55
+ setResults((prev) => new Map(prev).set(data.request_id, result));
56
+ onSuccessRef.current?.(result);
57
+ },
58
+ onFailure: (data) => {
59
+ const result = {
60
+ requestId: data.request_id,
61
+ status: "failed",
62
+ emailType: data.email_type,
63
+ recipient: data.recipient,
64
+ error: data.error,
65
+ };
66
+ setPending((prev) => {
67
+ const next = new Map(prev);
68
+ next.delete(data.request_id);
69
+ return next;
70
+ });
71
+ setResults((prev) => new Map(prev).set(data.request_id, result));
72
+ onFailureRef.current?.(result);
73
+ },
74
+ onTimeout: (rid) => {
75
+ const result = {
76
+ requestId: rid,
77
+ status: "timeout",
78
+ emailType,
79
+ };
80
+ setPending((prev) => {
81
+ const next = new Map(prev);
82
+ next.delete(rid);
83
+ return next;
84
+ });
85
+ setResults((prev) => new Map(prev).set(rid, result));
86
+ onTimeoutRef.current?.(rid);
87
+ },
88
+ });
89
+ setPending((prev) => new Map(prev).set(requestId, {
90
+ emailType,
91
+ trackedAt: Date.now(),
92
+ }));
93
+ return requestId;
94
+ }, [client, timeout]);
95
+ /* ── clearResult (optional cleanup) ─────────────────────────────── */
96
+ const clearResult = useCallback((requestId) => {
97
+ setResults((prev) => {
98
+ const next = new Map(prev);
99
+ next.delete(requestId);
100
+ return next;
101
+ });
102
+ }, []);
103
+ return { trackEmail, pending, results, clearResult };
104
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { AvroQueryClientConfig, AvroQueryClient } from './client/QueryClient';
2
2
  export { AvroQueryClientProvider, useAvroQueryClient, useSocketEvent } from './client/AvroQueryClientProvider';
3
+ export { useEmailStatus } from './client/hooks/email';
4
+ export type { EmailResult, EmailResultStatus, UseEmailStatusOptions } from './client/hooks/email';
3
5
  export { AuthManager } from './auth/AuthManager';
4
6
  export { MemoryStorage, LocalStorage } from './auth/storage';
5
7
  import './client/core/xhr';
@@ -28,6 +30,7 @@ import './client/hooks/skills';
28
30
  import './client/hooks/proposal';
29
31
  import './client/hooks/timecards';
30
32
  import './client/hooks/waivers';
33
+ import './client/hooks/email';
31
34
  export * from './types/api';
32
35
  export * from './types/auth';
33
36
  export * from './types/cache';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { AvroQueryClient } from './client/QueryClient';
2
2
  export { AvroQueryClientProvider, useAvroQueryClient, useSocketEvent } from './client/AvroQueryClientProvider';
3
+ export { useEmailStatus } from './client/hooks/email';
3
4
  export { AuthManager } from './auth/AuthManager';
4
5
  export { MemoryStorage, LocalStorage } from './auth/storage';
5
6
  import './client/core/xhr';
@@ -28,6 +29,7 @@ import './client/hooks/skills';
28
29
  import './client/hooks/proposal';
29
30
  import './client/hooks/timecards';
30
31
  import './client/hooks/waivers';
32
+ import './client/hooks/email';
31
33
  export * from './types/api';
32
34
  export * from './types/auth';
33
35
  export * from './types/cache';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Socket event payloads for email delivery notifications.
3
+ *
4
+ * The backend emits `email_succeeded` or `email_failed` after an email
5
+ * thread finishes. Both events include a `request_id` that the frontend
6
+ * uses to correlate the notification with the original HTTP request.
7
+ */
8
+ /** Provider-agnostic error shape emitted by the backend. */
9
+ export interface EmailError {
10
+ code: string;
11
+ message: string;
12
+ provider: "outlook" | "gmail" | "smtp";
13
+ }
14
+ export type EmailType = "bill" | "proposal" | "job" | "generic";
15
+ export interface EmailSucceededPayload {
16
+ request_id: string;
17
+ email_type: EmailType;
18
+ recipient?: string;
19
+ }
20
+ export interface EmailFailedPayload {
21
+ request_id: string;
22
+ email_type: EmailType;
23
+ error: EmailError;
24
+ recipient?: string;
25
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Socket event payloads for email delivery notifications.
3
+ *
4
+ * The backend emits `email_succeeded` or `email_failed` after an email
5
+ * thread finishes. Both events include a `request_id` that the frontend
6
+ * uses to correlate the notification with the original HTTP request.
7
+ */
8
+ export {};
@@ -11,6 +11,7 @@ export * from "../types/api/Chat";
11
11
  export * from "../types/api/Company";
12
12
  export * from "../types/api/CustomLineItem";
13
13
  export * from "../types/api/Email";
14
+ export * from "../types/api/EmailNotification";
14
15
  export * from "../types/api/Friendship";
15
16
  export * from "../types/api/Group";
16
17
  export * from "../types/api/Job";
package/dist/types/api.js CHANGED
@@ -10,6 +10,7 @@ export * from "../types/api/Chat";
10
10
  export * from "../types/api/Company";
11
11
  export * from "../types/api/CustomLineItem";
12
12
  export * from "../types/api/Email";
13
+ export * from "../types/api/EmailNotification";
13
14
  export * from "../types/api/Friendship";
14
15
  export * from "../types/api/Group";
15
16
  export * from "../types/api/Job";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-avro/avro-js",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "JS client for Avro backend integration.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -33,6 +33,7 @@
33
33
  "license": "CC-BY-SA-4.0",
34
34
  "devDependencies": {
35
35
  "@types/react": "^19.2.2",
36
+ "@types/uuid": "^10.0.0",
36
37
  "@typescript-eslint/eslint-plugin": "^8.38.0",
37
38
  "@typescript-eslint/parser": "^8.38.0",
38
39
  "eslint": "^8.57.1",
@@ -51,6 +52,7 @@
51
52
  },
52
53
  "dependencies": {
53
54
  "@tanstack/react-query": "^5.90.2",
54
- "socket.io-client": "^4.8.1"
55
+ "socket.io-client": "^4.8.1",
56
+ "uuid": "^14.0.0"
55
57
  }
56
58
  }