@sentroy-co/client-sdk 2.5.0 → 2.6.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.
Files changed (39) hide show
  1. package/README.md +114 -0
  2. package/dist/http.d.ts +8 -0
  3. package/dist/http.d.ts.map +1 -1
  4. package/dist/http.js +10 -0
  5. package/dist/http.js.map +1 -1
  6. package/dist/index.d.ts +9 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +12 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/react/lib/use-upload-queue.d.ts.map +1 -1
  11. package/dist/react/lib/use-upload-queue.js +61 -25
  12. package/dist/react/lib/use-upload-queue.js.map +1 -1
  13. package/dist/resources/audience.d.ts +60 -0
  14. package/dist/resources/audience.d.ts.map +1 -0
  15. package/dist/resources/audience.js +108 -0
  16. package/dist/resources/audience.js.map +1 -0
  17. package/dist/resources/logs.d.ts +15 -0
  18. package/dist/resources/logs.d.ts.map +1 -0
  19. package/dist/resources/logs.js +36 -0
  20. package/dist/resources/logs.js.map +1 -0
  21. package/dist/resources/suppressions.d.ts +20 -0
  22. package/dist/resources/suppressions.d.ts.map +1 -0
  23. package/dist/resources/suppressions.js +39 -0
  24. package/dist/resources/suppressions.js.map +1 -0
  25. package/dist/resources/webhooks.d.ts +21 -0
  26. package/dist/resources/webhooks.d.ts.map +1 -0
  27. package/dist/resources/webhooks.js +35 -0
  28. package/dist/resources/webhooks.js.map +1 -0
  29. package/dist/types.d.ts +128 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/http.ts +11 -0
  33. package/src/index.ts +30 -0
  34. package/src/react/lib/use-upload-queue.ts +64 -25
  35. package/src/resources/audience.ts +139 -0
  36. package/src/resources/logs.ts +27 -0
  37. package/src/resources/suppressions.ts +39 -0
  38. package/src/resources/webhooks.ts +47 -0
  39. package/src/types.ts +161 -0
@@ -66,6 +66,22 @@ export function useUploadQueue(
66
66
  // Worker pump — queued entry varsa ve aktif < concurrency ise başlat.
67
67
  const pumpRef = useRef<() => void>(() => {})
68
68
 
69
+ /**
70
+ * Aktif (in-flight) upload sayısını synchronous olarak takip eden ref.
71
+ * `entries` state setEntries ile async güncellendiğinden, recursive
72
+ * `pump` çağrılarında `entries.filter(e => e.status === "uploading")`
73
+ * eski listeyi görür → aynı queued entry birden çok kez başlatılır
74
+ * (Chrome side `ERR_INSUFFICIENT_RESOURCES`). Ref ile incre/decre
75
+ * synchronous; concurrency limiti gerçekten devreye girer.
76
+ */
77
+ const inFlightRef = useRef(0)
78
+ /**
79
+ * Henüz başlatılmamış queued entry id'lerinin sıralı listesi. setEntries
80
+ * async olduğu için listeden seçim yapmak yarış koşulu üretir; ref
81
+ * üzerinden FIFO push/shift hem deterministik hem hızlı.
82
+ */
83
+ const queueRef = useRef<string[]>([])
84
+
69
85
  const updateEntry = useCallback(
70
86
  (id: string, patch: Partial<UploadEntry>) => {
71
87
  setEntries((prev) =>
@@ -76,65 +92,77 @@ export function useUploadQueue(
76
92
  )
77
93
 
78
94
  pumpRef.current = () => {
79
- const list = entriesRef.current
80
- const active = list.filter((e) => e.status === "uploading").length
81
- if (active >= concurrency) return
82
- const next = list.find((e) => e.status === "queued")
83
- if (!next) return
95
+ // Slot doluysa veya queue boşsa erken dön.
96
+ if (inFlightRef.current >= concurrency) return
97
+ const nextId = queueRef.current.shift()
98
+ if (!nextId) return
99
+
100
+ // Slot'u synchronously rezerve et — bir sonraki pump çağrısı bu
101
+ // entry'i tekrar shift edemez.
102
+ inFlightRef.current++
103
+
104
+ const entry = entriesRef.current.find((e) => e.id === nextId)
105
+ if (!entry) {
106
+ // Cancel öncesi remove edilmiş; slot'u iade et ve pump'a devam.
107
+ inFlightRef.current--
108
+ pumpRef.current?.()
109
+ return
110
+ }
84
111
 
85
112
  // Mark uploading
86
- updateEntry(next.id, { status: "uploading" })
113
+ updateEntry(entry.id, { status: "uploading" })
87
114
 
88
- // Bucket slug entry içine save edilemiyor (entry shape'i bilmesi
89
- // gerekmez); enqueue closure'unda capture edilir, ayrı bucket map.
90
- const bucketSlug = bucketMapRef.current[next.id]
115
+ const bucketSlug = bucketMapRef.current[entry.id]
91
116
  if (!bucketSlug) {
92
- updateEntry(next.id, { status: "error", error: "No bucket" })
117
+ updateEntry(entry.id, { status: "error", error: "No bucket" })
118
+ inFlightRef.current--
93
119
  pumpRef.current?.()
94
120
  return
95
121
  }
96
122
 
97
123
  const controller = new AbortController()
98
- cancelMapRef.current[next.id] = () => controller.abort()
124
+ cancelMapRef.current[entry.id] = () => controller.abort()
99
125
 
100
126
  client.media
101
127
  .upload(
102
128
  bucketSlug,
103
- { body: next.file, filename: next.file.name },
129
+ { body: entry.file, filename: entry.file.name },
104
130
  {
105
131
  onProgress: (loaded, total) => {
106
- updateEntry(next.id, { loaded, total })
132
+ updateEntry(entry.id, { loaded, total })
107
133
  },
108
134
  signal: controller.signal,
109
135
  },
110
136
  )
111
137
  .then((media) => {
112
- updateEntry(next.id, {
138
+ updateEntry(entry.id, {
113
139
  status: "done",
114
140
  media,
115
- loaded: next.file.size,
116
- total: next.file.size,
141
+ loaded: entry.file.size,
142
+ total: entry.file.size,
117
143
  })
118
144
  onUploadedRef.current?.(media)
119
145
  })
120
146
  .catch((err: unknown) => {
121
147
  const aborted =
122
148
  (err as { message?: string })?.message === "Upload aborted"
123
- updateEntry(next.id, {
149
+ updateEntry(entry.id, {
124
150
  status: aborted ? "canceled" : "error",
125
151
  error: aborted
126
152
  ? undefined
127
- : (err as Error)?.message ?? "Upload failed",
153
+ : ((err as Error)?.message ?? "Upload failed"),
128
154
  })
129
155
  })
130
156
  .finally(() => {
131
- delete cancelMapRef.current[next.id]
132
- // Yeni slot açıldı; başka queued varsa al.
157
+ delete cancelMapRef.current[entry.id]
158
+ inFlightRef.current--
159
+ // Slot açıldı, sıradakini başlat.
133
160
  pumpRef.current?.()
134
161
  })
135
162
 
136
- // Aynı tick'te birden fazla slot doldur.
137
- if (active + 1 < concurrency) pumpRef.current?.()
163
+ // Aynı tick'te kalan slot'ları doldur — concurrency artık
164
+ // synchronously inFlightRef ile guard'lı, çift başlatma yok.
165
+ if (inFlightRef.current < concurrency) pumpRef.current?.()
138
166
  }
139
167
 
140
168
  const bucketMapRef = useRef<Record<string, string>>({})
@@ -146,6 +174,9 @@ export function useUploadQueue(
146
174
  const newEntries: UploadEntry[] = files.map((file) => {
147
175
  const id = `up-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
148
176
  bucketMapRef.current[id] = bucketSlug
177
+ // FIFO queue — pump bu sıradan shift eder; setEntries async
178
+ // güncellemesinden bağımsız synchronous source-of-truth.
179
+ queueRef.current.push(id)
149
180
  return {
150
181
  id,
151
182
  file,
@@ -155,7 +186,8 @@ export function useUploadQueue(
155
186
  cancel: () => {
156
187
  const c = cancelMapRef.current[id]
157
188
  if (c) c()
158
- else
189
+ else {
190
+ queueRef.current = queueRef.current.filter((qid) => qid !== id)
159
191
  setEntries((prev) =>
160
192
  prev.map((e) =>
161
193
  e.id === id && e.status === "queued"
@@ -163,11 +195,15 @@ export function useUploadQueue(
163
195
  : e,
164
196
  ),
165
197
  )
198
+ }
166
199
  },
167
200
  }
168
201
  })
169
202
  setEntries((prev) => [...prev, ...newEntries])
170
- // pump on next tick — state update'ten sonra entriesRef güncel olsun
203
+ // pump on next tick — state update'ten sonra entriesRef güncel olsun.
204
+ // pumpRef kendi içinde sequential pump zincirini sürdürür (her
205
+ // başarılı slot rezervasyonundan sonra bir dahaki pump'ı çağırır),
206
+ // dolayısıyla burada tek tetikleme yeterli.
171
207
  Promise.resolve().then(() => pumpRef.current?.())
172
208
  },
173
209
  [],
@@ -176,7 +212,8 @@ export function useUploadQueue(
176
212
  const cancel = useCallback((id: string) => {
177
213
  const c = cancelMapRef.current[id]
178
214
  if (c) c()
179
- else
215
+ else {
216
+ queueRef.current = queueRef.current.filter((qid) => qid !== id)
180
217
  setEntries((prev) =>
181
218
  prev.map((e) =>
182
219
  e.id === id && e.status === "queued"
@@ -184,10 +221,12 @@ export function useUploadQueue(
184
221
  : e,
185
222
  ),
186
223
  )
224
+ }
187
225
  }, [])
188
226
 
189
227
  const remove = useCallback((id: string) => {
190
228
  setEntries((prev) => prev.filter((e) => e.id !== id))
229
+ queueRef.current = queueRef.current.filter((qid) => qid !== id)
191
230
  delete bucketMapRef.current[id]
192
231
  delete cancelMapRef.current[id]
193
232
  }, [])
@@ -0,0 +1,139 @@
1
+ import type { HttpClient } from "../http"
2
+ import type {
3
+ Contact,
4
+ ContactList,
5
+ ContactListParams,
6
+ ContactListResult,
7
+ CreateAudienceListParams,
8
+ CreateContactParams,
9
+ UpdateContactParams,
10
+ } from "../types"
11
+
12
+ class AudienceListMembers {
13
+ constructor(
14
+ private http: HttpClient,
15
+ private listId: string,
16
+ ) {}
17
+
18
+ /** List all contacts in this audience list. */
19
+ async list(): Promise<Contact[]> {
20
+ return this.http.get<Contact[]>(
21
+ `/audience/lists/${encodeURIComponent(this.listId)}/members`,
22
+ )
23
+ }
24
+
25
+ /** Add a contact to the list by id. */
26
+ async add(contactId: string): Promise<void> {
27
+ await this.http.post<{ message: string }>(
28
+ `/audience/lists/${encodeURIComponent(this.listId)}/members`,
29
+ { contactId },
30
+ )
31
+ }
32
+
33
+ /** Remove a contact from the list. The contact record itself is preserved. */
34
+ async remove(contactId: string): Promise<void> {
35
+ await this.http.delWithBody<{ message: string }>(
36
+ `/audience/lists/${encodeURIComponent(this.listId)}/members`,
37
+ { contactId },
38
+ )
39
+ }
40
+ }
41
+
42
+ class AudienceLists {
43
+ constructor(private http: HttpClient) {}
44
+
45
+ /** List every audience list in the company. */
46
+ async list(): Promise<ContactList[]> {
47
+ return this.http.get<ContactList[]>("/audience/lists")
48
+ }
49
+
50
+ /** Get a single audience list by id. */
51
+ async get(id: string): Promise<ContactList> {
52
+ return this.http.get<ContactList>(
53
+ `/audience/lists/${encodeURIComponent(id)}`,
54
+ )
55
+ }
56
+
57
+ /** Create a new audience list. */
58
+ async create(params: CreateAudienceListParams): Promise<ContactList> {
59
+ return this.http.post<ContactList>("/audience/lists", params)
60
+ }
61
+
62
+ /** Delete an audience list. Contacts stay in the company; only the grouping is removed. */
63
+ async delete(id: string): Promise<void> {
64
+ await this.http.del<{ message: string }>(
65
+ `/audience/lists/${encodeURIComponent(id)}`,
66
+ )
67
+ }
68
+
69
+ /** Membership operations scoped to a list id. */
70
+ members(listId: string): AudienceListMembers {
71
+ return new AudienceListMembers(this.http, listId)
72
+ }
73
+ }
74
+
75
+ class AudienceContacts {
76
+ constructor(private http: HttpClient) {}
77
+
78
+ /**
79
+ * Paginated list of contacts. Filter by status or tag set; tags are
80
+ * sent as a comma-joined query param.
81
+ */
82
+ async list(params?: ContactListParams): Promise<ContactListResult> {
83
+ const query: Record<string, unknown> = {}
84
+ if (params?.page !== undefined) query.page = params.page
85
+ if (params?.limit !== undefined) query.limit = params.limit
86
+ if (params?.status) query.status = params.status
87
+ if (params?.tags?.length) query.tags = params.tags.join(",")
88
+ return this.http.get<ContactListResult>("/audience/contacts", query)
89
+ }
90
+
91
+ /**
92
+ * Email-prefix autocomplete. Capped server-side at 10 results — use
93
+ * `list` for paginated browsing.
94
+ */
95
+ async search(q: string): Promise<Contact[]> {
96
+ return this.http.get<Contact[]>("/audience/contacts", { q })
97
+ }
98
+
99
+ /** Get a single contact by id. */
100
+ async get(id: string): Promise<Contact> {
101
+ return this.http.get<Contact>(
102
+ `/audience/contacts/${encodeURIComponent(id)}`,
103
+ )
104
+ }
105
+
106
+ /** Create a contact. Defaults to status: "active". */
107
+ async create(params: CreateContactParams): Promise<Contact> {
108
+ return this.http.post<Contact>("/audience/contacts", params)
109
+ }
110
+
111
+ /** Patch any contact field. Pass `status` to mark unsubscribed/bounced. */
112
+ async update(id: string, params: UpdateContactParams): Promise<Contact> {
113
+ return this.http.patch<Contact>(
114
+ `/audience/contacts/${encodeURIComponent(id)}`,
115
+ params,
116
+ )
117
+ }
118
+
119
+ /**
120
+ * Soft-delete: marks the contact as `unsubscribed`. The record stays so
121
+ * historical mail-log foreign keys keep resolving and the email won't
122
+ * accidentally be re-added.
123
+ */
124
+ async delete(id: string): Promise<void> {
125
+ await this.http.del<{ message: string }>(
126
+ `/audience/contacts/${encodeURIComponent(id)}`,
127
+ )
128
+ }
129
+ }
130
+
131
+ export class Audience {
132
+ public readonly lists: AudienceLists
133
+ public readonly contacts: AudienceContacts
134
+
135
+ constructor(http: HttpClient) {
136
+ this.lists = new AudienceLists(http)
137
+ this.contacts = new AudienceContacts(http)
138
+ }
139
+ }
@@ -0,0 +1,27 @@
1
+ import type { HttpClient } from "../http"
2
+ import type { LogListParams, MailLog } from "../types"
3
+
4
+ export class Logs {
5
+ constructor(private http: HttpClient) {}
6
+
7
+ /**
8
+ * List mail-log entries. Filter by status, domain, and ISO timestamp
9
+ * range (`from` / `to`). Results are paginated server-side; pass `page`
10
+ * and `limit` to walk a large window.
11
+ */
12
+ async list(params?: LogListParams): Promise<MailLog[]> {
13
+ const query: Record<string, unknown> = {}
14
+ if (params?.page !== undefined) query.page = params.page
15
+ if (params?.limit !== undefined) query.limit = params.limit
16
+ if (params?.status) query.status = params.status
17
+ if (params?.domainId) query.domainId = params.domainId
18
+ if (params?.from) query.from = params.from
19
+ if (params?.to) query.to = params.to
20
+ return this.http.get<MailLog[]>("/logs", query)
21
+ }
22
+
23
+ /** Get a single mail-log entry by id. */
24
+ async get(id: string): Promise<MailLog> {
25
+ return this.http.get<MailLog>(`/logs/${encodeURIComponent(id)}`)
26
+ }
27
+ }
@@ -0,0 +1,39 @@
1
+ import type { HttpClient } from "../http"
2
+ import type {
3
+ AddSuppressionParams,
4
+ Suppression,
5
+ SuppressionListParams,
6
+ } from "../types"
7
+
8
+ export class Suppressions {
9
+ constructor(private http: HttpClient) {}
10
+
11
+ /**
12
+ * List suppressions across the company (or a single domain). Suppressed
13
+ * recipients are skipped at send time — every entry here is one address
14
+ * that will not receive mail until removed.
15
+ */
16
+ async list(params?: SuppressionListParams): Promise<Suppression[]> {
17
+ const query: Record<string, unknown> = {}
18
+ if (params?.page !== undefined) query.page = params.page
19
+ if (params?.limit !== undefined) query.limit = params.limit
20
+ if (params?.domainId) query.domainId = params.domainId
21
+ if (params?.reason) query.reason = params.reason
22
+ return this.http.get<Suppression[]>("/suppressions", query)
23
+ }
24
+
25
+ /**
26
+ * Manually suppress an address (e.g. honoring an off-platform opt-out).
27
+ * Bounces and complaints are added automatically by the mail server.
28
+ */
29
+ async add(params: AddSuppressionParams): Promise<Suppression> {
30
+ return this.http.post<Suppression>("/suppressions", params)
31
+ }
32
+
33
+ /** Remove a suppression — the address will be eligible to receive mail again. */
34
+ async remove(id: string): Promise<void> {
35
+ await this.http.del<{ message: string }>(
36
+ `/suppressions/${encodeURIComponent(id)}`,
37
+ )
38
+ }
39
+ }
@@ -0,0 +1,47 @@
1
+ import type { HttpClient } from "../http"
2
+ import type {
3
+ CreateWebhookParams,
4
+ UpdateWebhookParams,
5
+ Webhook,
6
+ } from "../types"
7
+
8
+ export class Webhooks {
9
+ constructor(private http: HttpClient) {}
10
+
11
+ /** List webhooks across the company, or scoped to a single domain. */
12
+ async list(domainId?: string): Promise<Webhook[]> {
13
+ return this.http.get<Webhook[]>(
14
+ "/webhooks",
15
+ domainId ? { domainId } : undefined,
16
+ )
17
+ }
18
+
19
+ /** Get a single webhook by id. */
20
+ async get(id: string): Promise<Webhook> {
21
+ return this.http.get<Webhook>(`/webhooks/${encodeURIComponent(id)}`)
22
+ }
23
+
24
+ /**
25
+ * Register a webhook for one or more events on a domain. The response
26
+ * includes a `secret` — store it now; subsequent reads only return the
27
+ * webhook config without the secret.
28
+ */
29
+ async create(params: CreateWebhookParams): Promise<Webhook> {
30
+ return this.http.post<Webhook>("/webhooks", params)
31
+ }
32
+
33
+ /** Patch URL, event list, or `active` flag. */
34
+ async update(id: string, params: UpdateWebhookParams): Promise<Webhook> {
35
+ return this.http.patch<Webhook>(
36
+ `/webhooks/${encodeURIComponent(id)}`,
37
+ params,
38
+ )
39
+ }
40
+
41
+ /** Delete a webhook. In-flight deliveries are not retried. */
42
+ async delete(id: string): Promise<void> {
43
+ await this.http.del<{ message: string }>(
44
+ `/webhooks/${encodeURIComponent(id)}`,
45
+ )
46
+ }
47
+ }
package/src/types.ts CHANGED
@@ -322,3 +322,164 @@ export interface StorageUsage {
322
322
  buckets: StorageUsageBucket[]
323
323
  byType: StorageUsageByType[]
324
324
  }
325
+
326
+ // ── Audience / Contacts ───────────────────────────────────────────────────
327
+
328
+ export type ContactStatus = "active" | "unsubscribed" | "bounced"
329
+
330
+ export interface Contact {
331
+ id: string
332
+ companyId: string
333
+ email: string
334
+ name?: string
335
+ tags: string[]
336
+ status: ContactStatus
337
+ metadata: Record<string, unknown>
338
+ lastEmailedAt?: string | null
339
+ createdAt: string
340
+ updatedAt: string
341
+ }
342
+
343
+ export interface ContactList {
344
+ id: string
345
+ companyId: string
346
+ name: string
347
+ description?: string
348
+ memberCount?: number
349
+ createdAt: string
350
+ updatedAt: string
351
+ }
352
+
353
+ export interface CreateContactParams {
354
+ email: string
355
+ name?: string
356
+ tags?: string[]
357
+ metadata?: Record<string, unknown>
358
+ }
359
+
360
+ export interface UpdateContactParams {
361
+ email?: string
362
+ name?: string
363
+ tags?: string[]
364
+ status?: ContactStatus
365
+ metadata?: Record<string, unknown>
366
+ }
367
+
368
+ export interface ContactListParams {
369
+ page?: number
370
+ limit?: number
371
+ status?: ContactStatus
372
+ /** Comma-joined when sent over the wire — pass an array, the SDK joins. */
373
+ tags?: string[]
374
+ }
375
+
376
+ export interface ContactListResult {
377
+ contacts: Contact[]
378
+ total: number
379
+ page: number
380
+ limit: number
381
+ }
382
+
383
+ export interface CreateAudienceListParams {
384
+ name: string
385
+ description?: string
386
+ }
387
+
388
+ // ── Suppressions ──────────────────────────────────────────────────────────
389
+
390
+ export interface Suppression {
391
+ id: string
392
+ email: string
393
+ reason: string
394
+ domainId: string
395
+ createdAt: string
396
+ domain?: { domain: string }
397
+ }
398
+
399
+ export interface AddSuppressionParams {
400
+ email: string
401
+ /** Free-form label (e.g. "manual", "complaint"). Defaults backend-side. */
402
+ reason?: string
403
+ domainId: string
404
+ }
405
+
406
+ export interface SuppressionListParams {
407
+ page?: number
408
+ limit?: number
409
+ domainId?: string
410
+ reason?: string
411
+ }
412
+
413
+ // ── Webhooks ──────────────────────────────────────────────────────────────
414
+
415
+ export type WebhookEvent =
416
+ | "sent"
417
+ | "bounced"
418
+ | "failed"
419
+ | "opened"
420
+ | "clicked"
421
+ | "unsubscribed"
422
+
423
+ export interface Webhook {
424
+ id: string
425
+ url: string
426
+ events: string[]
427
+ active: boolean
428
+ domainId: string
429
+ /** Returned only on create — used to verify HMAC signatures of deliveries. */
430
+ secret?: string
431
+ createdAt: string
432
+ updatedAt: string
433
+ }
434
+
435
+ export interface CreateWebhookParams {
436
+ url: string
437
+ events: WebhookEvent[] | string[]
438
+ domainId: string
439
+ }
440
+
441
+ export interface UpdateWebhookParams {
442
+ url?: string
443
+ events?: WebhookEvent[] | string[]
444
+ active?: boolean
445
+ }
446
+
447
+ // ── Logs ──────────────────────────────────────────────────────────────────
448
+
449
+ export type MailLogStatus =
450
+ | "queued"
451
+ | "processing"
452
+ | "sent"
453
+ | "bounced"
454
+ | "failed"
455
+
456
+ export interface MailLog {
457
+ id: string
458
+ to: string
459
+ from: string
460
+ subject: string
461
+ status: MailLogStatus
462
+ messageId: string | null
463
+ domainId: string
464
+ domain?: { domain: string }
465
+ templateId: string | null
466
+ variables: Record<string, unknown> | null
467
+ scheduledAt?: string | null
468
+ sentAt: string | null
469
+ bouncedAt: string | null
470
+ openedAt?: string | null
471
+ clickedAt?: string | null
472
+ error: string | null
473
+ createdAt: string
474
+ }
475
+
476
+ export interface LogListParams {
477
+ page?: number
478
+ limit?: number
479
+ status?: MailLogStatus
480
+ domainId?: string
481
+ /** ISO timestamp lower bound (inclusive). */
482
+ from?: string
483
+ /** ISO timestamp upper bound (inclusive). */
484
+ to?: string
485
+ }