perspectapi-ts-sdk 3.5.1 → 3.7.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.
@@ -4,10 +4,13 @@
4
4
 
5
5
  import { BaseClient } from './base-client';
6
6
  import type { CacheManager } from '../cache/cache-manager';
7
+ import type { CachePolicy } from '../cache/types';
7
8
  import type {
8
9
  NewsletterSubscription,
9
10
  CreateNewsletterSubscriptionRequest,
10
11
  NewsletterList,
12
+ NewsletterCampaignDetail,
13
+ NewsletterCampaignListResponse,
11
14
  NewsletterPreferences,
12
15
  NewsletterStatusResponse,
13
16
  NewsletterSubscribeResponse,
@@ -17,6 +20,7 @@ import type {
17
20
  PaginatedResponse,
18
21
  ApiResponse,
19
22
  } from '../types';
23
+ import { validateOptionalLimit } from '../utils/validators';
20
24
 
21
25
  export class NewsletterClient extends BaseClient {
22
26
  constructor(http: any, cache?: CacheManager) {
@@ -119,15 +123,174 @@ export class NewsletterClient extends BaseClient {
119
123
  /**
120
124
  * Get available newsletter lists
121
125
  */
122
- async getLists(siteName: string): Promise<ApiResponse<{
126
+ async getLists(
127
+ siteName: string,
128
+ cachePolicy?: CachePolicy
129
+ ): Promise<ApiResponse<{
123
130
  lists: NewsletterList[];
124
131
  total: number;
125
132
  }>> {
126
- return this.getSingle(
127
- this.newsletterEndpoint(siteName, '/newsletter/lists')
133
+ const endpoint = this.newsletterEndpoint(siteName, '/newsletter/lists');
134
+ const path = this.buildPath(endpoint);
135
+
136
+ return this.fetchWithCache<ApiResponse<{
137
+ lists: NewsletterList[];
138
+ total: number;
139
+ }>>(
140
+ endpoint,
141
+ undefined,
142
+ this.buildNewsletterTags(siteName, [
143
+ 'newsletter:lists',
144
+ `newsletter:lists:site:${siteName}`,
145
+ ]),
146
+ cachePolicy,
147
+ () => this.http.get(path)
148
+ );
149
+ }
150
+
151
+ /**
152
+ * List publicly available (sent) newsletter campaigns
153
+ */
154
+ async getPublishedCampaigns(
155
+ siteName: string,
156
+ params?: {
157
+ page?: number;
158
+ limit?: number;
159
+ search?: string;
160
+ },
161
+ cachePolicy?: CachePolicy
162
+ ): Promise<ApiResponse<NewsletterCampaignListResponse>> {
163
+ const endpoint = this.newsletterEndpoint(siteName, '/newsletter/campaigns');
164
+ const path = this.buildPath(endpoint);
165
+ const normalizedParams = params ? { ...params } : undefined;
166
+
167
+ if (normalizedParams) {
168
+ const validatedLimit = validateOptionalLimit(
169
+ normalizedParams.limit,
170
+ 'newsletter campaigns query',
171
+ );
172
+ if (validatedLimit !== undefined) {
173
+ normalizedParams.limit = validatedLimit;
174
+ } else {
175
+ delete normalizedParams.limit;
176
+ }
177
+
178
+ if (
179
+ typeof normalizedParams.search === 'string' &&
180
+ normalizedParams.search.trim().length === 0
181
+ ) {
182
+ delete normalizedParams.search;
183
+ }
184
+ }
185
+
186
+ return this.fetchWithCache<ApiResponse<NewsletterCampaignListResponse>>(
187
+ endpoint,
188
+ normalizedParams,
189
+ this.buildNewsletterTags(siteName, [
190
+ 'newsletter:campaigns',
191
+ `newsletter:campaigns:list:${siteName}`,
192
+ ]),
193
+ cachePolicy,
194
+ () => this.http.get<NewsletterCampaignListResponse>(path, normalizedParams)
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Fetch a publicly available (sent) newsletter campaign by slug
200
+ */
201
+ async getPublishedCampaignBySlug(
202
+ siteName: string,
203
+ slug: string,
204
+ optionsOrPolicy?: { slugPrefix?: string } | CachePolicy,
205
+ cachePolicy?: CachePolicy
206
+ ): Promise<ApiResponse<NewsletterCampaignDetail>> {
207
+ let normalizedSlug = slug.trim();
208
+ if (!normalizedSlug) {
209
+ throw new Error('slug is required');
210
+ }
211
+
212
+ const isCachePolicyArg = this.isCachePolicy(optionsOrPolicy);
213
+ const providedSlugPrefix =
214
+ !isCachePolicyArg && optionsOrPolicy
215
+ ? this.pickString(optionsOrPolicy as Record<string, unknown>, ['slugPrefix', 'slug_prefix'])
216
+ : undefined;
217
+ const resolvedCachePolicy = isCachePolicyArg
218
+ ? (optionsOrPolicy as CachePolicy)
219
+ : cachePolicy;
220
+
221
+ let slugPrefix = providedSlugPrefix;
222
+ if (!slugPrefix) {
223
+ const parts = normalizedSlug.split('/').filter(Boolean);
224
+ if (parts.length > 1) {
225
+ slugPrefix = parts.slice(0, -1).join('/');
226
+ normalizedSlug = parts[parts.length - 1];
227
+ }
228
+ }
229
+ normalizedSlug = normalizedSlug.trim();
230
+ if (!normalizedSlug) {
231
+ throw new Error('slug is required');
232
+ }
233
+
234
+ const endpoint = this.newsletterEndpoint(
235
+ siteName,
236
+ `/newsletter/campaigns/${encodeURIComponent(normalizedSlug)}`
237
+ );
238
+ const path = this.buildPath(endpoint);
239
+ const queryParams = slugPrefix ? { slug_prefix: slugPrefix } : undefined;
240
+
241
+ return this.fetchWithCache<ApiResponse<NewsletterCampaignDetail>>(
242
+ endpoint,
243
+ queryParams,
244
+ this.buildNewsletterTags(
245
+ siteName,
246
+ [
247
+ 'newsletter:campaigns',
248
+ `newsletter:campaigns:slug:${siteName}:${normalizedSlug}`,
249
+ `newsletter:campaigns:detail:${siteName}`,
250
+ ],
251
+ slugPrefix
252
+ ),
253
+ resolvedCachePolicy,
254
+ () => this.http.get<NewsletterCampaignDetail>(path, queryParams)
128
255
  );
129
256
  }
130
257
 
258
+ /**
259
+ * Invalidate cached published newsletter campaign data from a webhook payload.
260
+ * Only publish events (and legacy sent aliases) trigger invalidation.
261
+ */
262
+ async invalidatePublishedCampaignCacheFromWebhook(
263
+ payload: Record<string, unknown>,
264
+ fallbackSiteName?: string,
265
+ ): Promise<{ invalidated: boolean; tags: string[]; reason?: string }> {
266
+ const normalized = this.normalizeNewsletterWebhookPayload(payload, fallbackSiteName);
267
+ if (!normalized.shouldInvalidate) {
268
+ return {
269
+ invalidated: false,
270
+ tags: [],
271
+ reason: normalized.reason || 'event_not_publish_related',
272
+ };
273
+ }
274
+
275
+ const tags = this.buildNewsletterTags(
276
+ normalized.siteName,
277
+ [
278
+ 'newsletter:campaigns',
279
+ `newsletter:campaigns:list:${normalized.siteName}`,
280
+ normalized.slug
281
+ ? `newsletter:campaigns:slug:${normalized.siteName}:${normalized.slug}`
282
+ : '',
283
+ normalized.campaignId
284
+ ? `newsletter:campaigns:id:${normalized.siteName}:${normalized.campaignId}`
285
+ : '',
286
+ ],
287
+ normalized.slugPrefix,
288
+ );
289
+
290
+ await this.invalidateCache({ tags });
291
+ return { invalidated: true, tags };
292
+ }
293
+
131
294
  /**
132
295
  * Check subscription status by email
133
296
  */
@@ -432,4 +595,181 @@ export class NewsletterClient extends BaseClient {
432
595
  0x44, 0x01, 0x00, 0x3b,
433
596
  ]);
434
597
  }
598
+
599
+ private buildNewsletterTags(
600
+ siteName?: string,
601
+ extraTags: string[] = [],
602
+ slugPrefix?: string
603
+ ): string[] {
604
+ const tags = new Set<string>(['newsletter']);
605
+ if (siteName) {
606
+ tags.add(`newsletter:site:${siteName}`);
607
+ }
608
+ if (slugPrefix) {
609
+ tags.add(`newsletter:prefix:${slugPrefix}`);
610
+ }
611
+ extraTags.filter(Boolean).forEach(tag => tags.add(tag));
612
+ return Array.from(tags.values());
613
+ }
614
+
615
+ private extractSlugPrefix(slug: string): string | undefined {
616
+ const normalized = slug.trim();
617
+ if (!normalized.includes('/')) {
618
+ return undefined;
619
+ }
620
+ const [prefix] = normalized.split('/').filter(Boolean);
621
+ return prefix || undefined;
622
+ }
623
+
624
+ private normalizeNewsletterWebhookPayload(
625
+ payload: Record<string, unknown>,
626
+ fallbackSiteName?: string
627
+ ): {
628
+ shouldInvalidate: boolean;
629
+ reason?: string;
630
+ siteName: string;
631
+ slug?: string;
632
+ slugPrefix?: string;
633
+ campaignId?: string;
634
+ } {
635
+ const eventType = (
636
+ this.pickString(payload, ['event_type', 'type']) || ''
637
+ ).toLowerCase();
638
+
639
+ const dataRaw = payload.data;
640
+ const data = (dataRaw && typeof dataRaw === 'object' && !Array.isArray(dataRaw))
641
+ ? (dataRaw as Record<string, unknown>)
642
+ : payload;
643
+
644
+ const originType = (
645
+ this.pickString(data, ['origin_type', 'originType']) || ''
646
+ ).toLowerCase();
647
+ const status = (
648
+ this.pickString(data, ['status', 'campaign_status', 'campaignStatus']) || ''
649
+ ).toLowerCase();
650
+
651
+ if (!this.isPublishEvent(eventType, status, originType)) {
652
+ return {
653
+ shouldInvalidate: false,
654
+ reason: 'not_publish_or_sent_event',
655
+ siteName: fallbackSiteName || '',
656
+ };
657
+ }
658
+
659
+ const siteName =
660
+ this.pickString(payload, ['site', 'site_name', 'siteName']) ||
661
+ this.pickString(data, ['site', 'site_name', 'siteName']) ||
662
+ fallbackSiteName ||
663
+ '';
664
+
665
+ if (!siteName) {
666
+ return {
667
+ shouldInvalidate: false,
668
+ reason: 'missing_site_name',
669
+ siteName: '',
670
+ };
671
+ }
672
+
673
+ let slug =
674
+ this.pickString(data, ['slug', 'canonical_slug', 'canonicalSlug']) ||
675
+ this.pickString(payload, ['slug', 'canonical_slug', 'canonicalSlug']) ||
676
+ undefined;
677
+
678
+ let slugPrefix =
679
+ this.pickString(data, ['slug_prefix', 'slugPrefix']) ||
680
+ this.pickString(payload, ['slug_prefix', 'slugPrefix']) ||
681
+ (slug ? this.extractSlugPrefix(slug) : undefined);
682
+
683
+ if (slug) {
684
+ const slugParts = slug.split('/').filter(Boolean);
685
+ if (slugParts.length > 1) {
686
+ if (!slugPrefix) {
687
+ slugPrefix = slugParts.slice(0, -1).join('/');
688
+ }
689
+ slug = slugParts[slugParts.length - 1];
690
+ }
691
+ }
692
+
693
+ const campaignId =
694
+ this.pickString(data, ['campaign_id', 'campaignId']) ||
695
+ this.pickString(payload, ['campaign_id', 'campaignId']) ||
696
+ undefined;
697
+
698
+ return {
699
+ shouldInvalidate: true,
700
+ siteName,
701
+ slug,
702
+ slugPrefix,
703
+ campaignId,
704
+ };
705
+ }
706
+
707
+ private isPublishEvent(
708
+ eventType: string,
709
+ status: string,
710
+ originType: string
711
+ ): boolean {
712
+ const knownPublishEvents = new Set([
713
+ 'newsletter.published',
714
+ 'newsletter.sent',
715
+ 'newsletter_campaign.published',
716
+ 'newsletter_campaign.sent',
717
+ 'newsletter.campaign.published',
718
+ 'newsletter.campaign.sent',
719
+ 'campaign.published',
720
+ 'campaign.sent',
721
+ ]);
722
+
723
+ if (knownPublishEvents.has(eventType)) {
724
+ return true;
725
+ }
726
+
727
+ // Content webhook event for unified newsletter campaign content.
728
+ if (eventType === 'content.published' && originType === 'newsletter_campaign') {
729
+ return true;
730
+ }
731
+
732
+ // Some systems emit update events with final status.
733
+ if (
734
+ (eventType.includes('newsletter') || eventType.includes('campaign')) &&
735
+ eventType.endsWith('.updated') &&
736
+ (status === 'published' || status === 'sent')
737
+ ) {
738
+ return true;
739
+ }
740
+
741
+ return false;
742
+ }
743
+
744
+ private pickString(
745
+ source: Record<string, unknown> | undefined,
746
+ keys: string[]
747
+ ): string | undefined {
748
+ if (!source) {
749
+ return undefined;
750
+ }
751
+
752
+ for (const key of keys) {
753
+ const value = source[key];
754
+ if (typeof value === 'string' && value.trim().length > 0) {
755
+ return value.trim();
756
+ }
757
+ }
758
+
759
+ return undefined;
760
+ }
761
+
762
+ private isCachePolicy(value: unknown): value is CachePolicy {
763
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
764
+ return false;
765
+ }
766
+
767
+ const record = value as Record<string, unknown>;
768
+ return (
769
+ 'ttlSeconds' in record ||
770
+ 'tags' in record ||
771
+ 'metadata' in record ||
772
+ 'skipCache' in record
773
+ );
774
+ }
435
775
  }
@@ -8,6 +8,8 @@ import type { CachePolicy } from '../cache/types';
8
8
  import type {
9
9
  Product,
10
10
  CreateProductRequest,
11
+ CreateProductSkuRequest,
12
+ ProductSku,
11
13
  PaginatedResponse,
12
14
  ApiResponse,
13
15
  ProductQueryParams,
@@ -457,17 +459,7 @@ export class ProductsClient extends BaseClient {
457
459
  async getProductSkus(
458
460
  siteName: string,
459
461
  productId: number
460
- ): Promise<ApiResponse<Array<{
461
- sku_id: number;
462
- sku: string;
463
- price?: number;
464
- sale_price?: number;
465
- stock_quantity?: number;
466
- combination_key: string;
467
- value_ids: number[];
468
- created_at: string;
469
- updated_at: string;
470
- }>>> {
462
+ ): Promise<ApiResponse<ProductSku[]>> {
471
463
  const endpoint = this.siteScopedEndpoint(
472
464
  siteName,
473
465
  `/products/${productId}/skus`,
@@ -482,26 +474,66 @@ export class ProductsClient extends BaseClient {
482
474
  async createProductSku(
483
475
  siteName: string,
484
476
  productId: number,
485
- data: {
486
- sku: string;
487
- price?: number | null;
488
- sale_price?: number | null;
489
- stock_quantity?: number | null;
490
- value_ids: number[];
491
- }
492
- ): Promise<ApiResponse<{
493
- sku_id: number;
494
- sku: string;
495
- price?: number;
496
- sale_price?: number;
497
- stock_quantity?: number;
498
- combination_key: string;
499
- }>> {
477
+ data: CreateProductSkuRequest
478
+ ): Promise<ApiResponse<ProductSku>> {
500
479
  const endpoint = this.siteScopedEndpoint(
501
480
  siteName,
502
481
  `/products/${productId}/skus`,
503
482
  { includeSitesSegment: false }
504
483
  );
505
- return this.create(endpoint, data);
484
+
485
+ const unitAmountCandidate =
486
+ typeof data.unit_amount === 'number'
487
+ ? data.unit_amount
488
+ : typeof data.price === 'number'
489
+ ? Math.round(data.price * 100)
490
+ : undefined;
491
+
492
+ if (typeof unitAmountCandidate !== 'number' || !Number.isFinite(unitAmountCandidate)) {
493
+ throw new Error('createProductSku requires unit_amount or price');
494
+ }
495
+ const unitAmount = unitAmountCandidate;
496
+
497
+ const quantityAvailable =
498
+ data.quantity_available !== undefined
499
+ ? data.quantity_available
500
+ : (data.stock_quantity ?? null);
501
+
502
+ const payload: {
503
+ sku: string | null;
504
+ media_id?: string | null;
505
+ unit_amount: number;
506
+ currency: string;
507
+ quantity_available: number | null;
508
+ published?: boolean;
509
+ gateway_price_id_test?: string | null;
510
+ gateway_price_id_live?: string | null;
511
+ value_ids: number[];
512
+ } = {
513
+ sku: data.sku ?? null,
514
+ unit_amount: unitAmount,
515
+ currency: data.currency || 'usd',
516
+ quantity_available: quantityAvailable,
517
+ published: data.published,
518
+ gateway_price_id_test: data.gateway_price_id_test,
519
+ gateway_price_id_live: data.gateway_price_id_live,
520
+ value_ids: data.value_ids,
521
+ };
522
+
523
+ if (Object.prototype.hasOwnProperty.call(data, 'media_id')) {
524
+ if (data.media_id === null) {
525
+ payload.media_id = null;
526
+ } else if (typeof data.media_id === 'string') {
527
+ const trimmed = data.media_id.trim();
528
+ if (!trimmed) {
529
+ throw new Error('createProductSku media_id cannot be empty');
530
+ }
531
+ payload.media_id = trimmed;
532
+ } else if (data.media_id !== undefined) {
533
+ throw new Error('createProductSku media_id must be a string or null');
534
+ }
535
+ }
536
+
537
+ return this.create(endpoint, payload);
506
538
  }
507
539
  }
package/src/index.ts CHANGED
@@ -100,6 +100,9 @@ export type {
100
100
  NewsletterSubscription,
101
101
  CreateNewsletterSubscriptionRequest,
102
102
  NewsletterList,
103
+ NewsletterCampaignSummary,
104
+ NewsletterCampaignDetail,
105
+ NewsletterCampaignListResponse,
103
106
  NewsletterPreferences,
104
107
  NewsletterStatusResponse,
105
108
  NewsletterSubscribeResponse,
@@ -141,6 +141,37 @@ export interface NewsletterList {
141
141
  subscriber_count?: number;
142
142
  }
143
143
 
144
+ export interface NewsletterCampaignSummary {
145
+ id: string;
146
+ campaign_name: string;
147
+ slug: string;
148
+ slug_prefix?: string | null;
149
+ subject: string;
150
+ preview_text?: string | null;
151
+ status: string;
152
+ sent_at?: string | null;
153
+ completed_at?: string | null;
154
+ created_at: string;
155
+ updated_at: string;
156
+ }
157
+
158
+ export interface NewsletterCampaignDetail extends NewsletterCampaignSummary {
159
+ markdown_content?: string | null;
160
+ html_content?: string | null;
161
+ text_content?: string | null;
162
+ excerpt?: string | null;
163
+ }
164
+
165
+ export interface NewsletterCampaignListResponse {
166
+ items: NewsletterCampaignSummary[];
167
+ pagination: {
168
+ page: number;
169
+ limit: number;
170
+ total: number;
171
+ pages: number;
172
+ };
173
+ }
174
+
144
175
  export interface NewsletterPreferences {
145
176
  frequency?: 'instant' | 'daily' | 'weekly' | 'monthly';
146
177
  topics?: string[];
@@ -289,6 +320,49 @@ export interface MediaItem {
289
320
  site_name: string;
290
321
  }
291
322
 
323
+ export interface ProductSkuMediaItem {
324
+ id?: string;
325
+ media_id: string;
326
+ file_name?: string;
327
+ fileName?: string;
328
+ link?: string;
329
+ url?: string;
330
+ content_type?: string;
331
+ contentType?: string;
332
+ width?: number;
333
+ height?: number;
334
+ filesize?: number;
335
+ r2_key?: string;
336
+ site_name?: string;
337
+ }
338
+
339
+ export interface ProductSkuOption {
340
+ name: string;
341
+ key: string;
342
+ value: string;
343
+ label: string;
344
+ value_id: number;
345
+ }
346
+
347
+ export interface ProductSku {
348
+ sku_id: number;
349
+ sku?: string | null;
350
+ combination_key: string;
351
+ media_id?: string | null;
352
+ media?: ProductSkuMediaItem | null;
353
+ options?: ProductSkuOption[];
354
+ unit_amount?: number;
355
+ currency?: string;
356
+ quantity_available?: number | null;
357
+ price?: number;
358
+ sale_price?: number;
359
+ stock_quantity?: number;
360
+ value_ids: number[];
361
+ created_at?: string;
362
+ updated_at?: string;
363
+ [key: string]: any;
364
+ }
365
+
292
366
  export interface Product {
293
367
  id: number | string;
294
368
  name?: string;
@@ -302,6 +376,7 @@ export interface Product {
302
376
  slug_prefix?: string;
303
377
  image?: string;
304
378
  media?: MediaItem[] | MediaItem[][];
379
+ skus?: ProductSku[];
305
380
  isActive?: boolean;
306
381
  organizationId?: number;
307
382
  createdAt?: string;
@@ -331,6 +406,21 @@ export interface CreateProductRequest {
331
406
  isActive?: boolean;
332
407
  }
333
408
 
409
+ export interface CreateProductSkuRequest {
410
+ sku?: string | null;
411
+ media_id?: string | null;
412
+ unit_amount?: number;
413
+ currency?: string;
414
+ quantity_available?: number | null;
415
+ price?: number | null;
416
+ stock_quantity?: number | null;
417
+ sale_price?: number | null;
418
+ published?: boolean;
419
+ gateway_price_id_test?: string | null;
420
+ gateway_price_id_live?: string | null;
421
+ value_ids: number[];
422
+ }
423
+
334
424
  export interface ProductQueryParams extends PaginationParams {
335
425
  organizationId?: number;
336
426
  isActive?: boolean;