perspectapi-ts-sdk 3.5.0 → 3.6.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/dist/index.mjs CHANGED
@@ -2003,11 +2003,123 @@ var NewsletterClient = class extends BaseClient {
2003
2003
  /**
2004
2004
  * Get available newsletter lists
2005
2005
  */
2006
- async getLists(siteName) {
2007
- return this.getSingle(
2008
- this.newsletterEndpoint(siteName, "/newsletter/lists")
2006
+ async getLists(siteName, cachePolicy) {
2007
+ const endpoint = this.newsletterEndpoint(siteName, "/newsletter/lists");
2008
+ const path = this.buildPath(endpoint);
2009
+ return this.fetchWithCache(
2010
+ endpoint,
2011
+ void 0,
2012
+ this.buildNewsletterTags(siteName, [
2013
+ "newsletter:lists",
2014
+ `newsletter:lists:site:${siteName}`
2015
+ ]),
2016
+ cachePolicy,
2017
+ () => this.http.get(path)
2009
2018
  );
2010
2019
  }
2020
+ /**
2021
+ * List publicly available (sent) newsletter campaigns
2022
+ */
2023
+ async getPublishedCampaigns(siteName, params, cachePolicy) {
2024
+ const endpoint = this.newsletterEndpoint(siteName, "/newsletter/campaigns");
2025
+ const path = this.buildPath(endpoint);
2026
+ const normalizedParams = params ? { ...params } : void 0;
2027
+ if (normalizedParams) {
2028
+ const validatedLimit = validateOptionalLimit(
2029
+ normalizedParams.limit,
2030
+ "newsletter campaigns query"
2031
+ );
2032
+ if (validatedLimit !== void 0) {
2033
+ normalizedParams.limit = validatedLimit;
2034
+ } else {
2035
+ delete normalizedParams.limit;
2036
+ }
2037
+ if (typeof normalizedParams.search === "string" && normalizedParams.search.trim().length === 0) {
2038
+ delete normalizedParams.search;
2039
+ }
2040
+ }
2041
+ return this.fetchWithCache(
2042
+ endpoint,
2043
+ normalizedParams,
2044
+ this.buildNewsletterTags(siteName, [
2045
+ "newsletter:campaigns",
2046
+ `newsletter:campaigns:list:${siteName}`
2047
+ ]),
2048
+ cachePolicy,
2049
+ () => this.http.get(path, normalizedParams)
2050
+ );
2051
+ }
2052
+ /**
2053
+ * Fetch a publicly available (sent) newsletter campaign by slug
2054
+ */
2055
+ async getPublishedCampaignBySlug(siteName, slug, optionsOrPolicy, cachePolicy) {
2056
+ let normalizedSlug = slug.trim();
2057
+ if (!normalizedSlug) {
2058
+ throw new Error("slug is required");
2059
+ }
2060
+ const isCachePolicyArg = this.isCachePolicy(optionsOrPolicy);
2061
+ const providedSlugPrefix = !isCachePolicyArg && optionsOrPolicy ? this.pickString(optionsOrPolicy, ["slugPrefix", "slug_prefix"]) : void 0;
2062
+ const resolvedCachePolicy = isCachePolicyArg ? optionsOrPolicy : cachePolicy;
2063
+ let slugPrefix = providedSlugPrefix;
2064
+ if (!slugPrefix) {
2065
+ const parts = normalizedSlug.split("/").filter(Boolean);
2066
+ if (parts.length > 1) {
2067
+ slugPrefix = parts.slice(0, -1).join("/");
2068
+ normalizedSlug = parts[parts.length - 1];
2069
+ }
2070
+ }
2071
+ normalizedSlug = normalizedSlug.trim();
2072
+ if (!normalizedSlug) {
2073
+ throw new Error("slug is required");
2074
+ }
2075
+ const endpoint = this.newsletterEndpoint(
2076
+ siteName,
2077
+ `/newsletter/campaigns/${encodeURIComponent(normalizedSlug)}`
2078
+ );
2079
+ const path = this.buildPath(endpoint);
2080
+ const queryParams = slugPrefix ? { slug_prefix: slugPrefix } : void 0;
2081
+ return this.fetchWithCache(
2082
+ endpoint,
2083
+ queryParams,
2084
+ this.buildNewsletterTags(
2085
+ siteName,
2086
+ [
2087
+ "newsletter:campaigns",
2088
+ `newsletter:campaigns:slug:${siteName}:${normalizedSlug}`,
2089
+ `newsletter:campaigns:detail:${siteName}`
2090
+ ],
2091
+ slugPrefix
2092
+ ),
2093
+ resolvedCachePolicy,
2094
+ () => this.http.get(path, queryParams)
2095
+ );
2096
+ }
2097
+ /**
2098
+ * Invalidate cached published newsletter campaign data from a webhook payload.
2099
+ * Only publish events (and legacy sent aliases) trigger invalidation.
2100
+ */
2101
+ async invalidatePublishedCampaignCacheFromWebhook(payload, fallbackSiteName) {
2102
+ const normalized = this.normalizeNewsletterWebhookPayload(payload, fallbackSiteName);
2103
+ if (!normalized.shouldInvalidate) {
2104
+ return {
2105
+ invalidated: false,
2106
+ tags: [],
2107
+ reason: normalized.reason || "event_not_publish_related"
2108
+ };
2109
+ }
2110
+ const tags = this.buildNewsletterTags(
2111
+ normalized.siteName,
2112
+ [
2113
+ "newsletter:campaigns",
2114
+ `newsletter:campaigns:list:${normalized.siteName}`,
2115
+ normalized.slug ? `newsletter:campaigns:slug:${normalized.siteName}:${normalized.slug}` : "",
2116
+ normalized.campaignId ? `newsletter:campaigns:id:${normalized.siteName}:${normalized.campaignId}` : ""
2117
+ ],
2118
+ normalized.slugPrefix
2119
+ );
2120
+ await this.invalidateCache({ tags });
2121
+ return { invalidated: true, tags };
2122
+ }
2011
2123
  /**
2012
2124
  * Check subscription status by email
2013
2125
  */
@@ -2203,6 +2315,107 @@ var NewsletterClient = class extends BaseClient {
2203
2315
  59
2204
2316
  ]);
2205
2317
  }
2318
+ buildNewsletterTags(siteName, extraTags = [], slugPrefix) {
2319
+ const tags = /* @__PURE__ */ new Set(["newsletter"]);
2320
+ if (siteName) {
2321
+ tags.add(`newsletter:site:${siteName}`);
2322
+ }
2323
+ if (slugPrefix) {
2324
+ tags.add(`newsletter:prefix:${slugPrefix}`);
2325
+ }
2326
+ extraTags.filter(Boolean).forEach((tag) => tags.add(tag));
2327
+ return Array.from(tags.values());
2328
+ }
2329
+ extractSlugPrefix(slug) {
2330
+ const normalized = slug.trim();
2331
+ if (!normalized.includes("/")) {
2332
+ return void 0;
2333
+ }
2334
+ const [prefix] = normalized.split("/").filter(Boolean);
2335
+ return prefix || void 0;
2336
+ }
2337
+ normalizeNewsletterWebhookPayload(payload, fallbackSiteName) {
2338
+ const eventType = (this.pickString(payload, ["event_type", "type"]) || "").toLowerCase();
2339
+ const dataRaw = payload.data;
2340
+ const data = dataRaw && typeof dataRaw === "object" && !Array.isArray(dataRaw) ? dataRaw : payload;
2341
+ const originType = (this.pickString(data, ["origin_type", "originType"]) || "").toLowerCase();
2342
+ const status = (this.pickString(data, ["status", "campaign_status", "campaignStatus"]) || "").toLowerCase();
2343
+ if (!this.isPublishEvent(eventType, status, originType)) {
2344
+ return {
2345
+ shouldInvalidate: false,
2346
+ reason: "not_publish_or_sent_event",
2347
+ siteName: fallbackSiteName || ""
2348
+ };
2349
+ }
2350
+ const siteName = this.pickString(payload, ["site", "site_name", "siteName"]) || this.pickString(data, ["site", "site_name", "siteName"]) || fallbackSiteName || "";
2351
+ if (!siteName) {
2352
+ return {
2353
+ shouldInvalidate: false,
2354
+ reason: "missing_site_name",
2355
+ siteName: ""
2356
+ };
2357
+ }
2358
+ let slug = this.pickString(data, ["slug", "canonical_slug", "canonicalSlug"]) || this.pickString(payload, ["slug", "canonical_slug", "canonicalSlug"]) || void 0;
2359
+ let slugPrefix = this.pickString(data, ["slug_prefix", "slugPrefix"]) || this.pickString(payload, ["slug_prefix", "slugPrefix"]) || (slug ? this.extractSlugPrefix(slug) : void 0);
2360
+ if (slug) {
2361
+ const slugParts = slug.split("/").filter(Boolean);
2362
+ if (slugParts.length > 1) {
2363
+ if (!slugPrefix) {
2364
+ slugPrefix = slugParts.slice(0, -1).join("/");
2365
+ }
2366
+ slug = slugParts[slugParts.length - 1];
2367
+ }
2368
+ }
2369
+ const campaignId = this.pickString(data, ["campaign_id", "campaignId"]) || this.pickString(payload, ["campaign_id", "campaignId"]) || void 0;
2370
+ return {
2371
+ shouldInvalidate: true,
2372
+ siteName,
2373
+ slug,
2374
+ slugPrefix,
2375
+ campaignId
2376
+ };
2377
+ }
2378
+ isPublishEvent(eventType, status, originType) {
2379
+ const knownPublishEvents = /* @__PURE__ */ new Set([
2380
+ "newsletter.published",
2381
+ "newsletter.sent",
2382
+ "newsletter_campaign.published",
2383
+ "newsletter_campaign.sent",
2384
+ "newsletter.campaign.published",
2385
+ "newsletter.campaign.sent",
2386
+ "campaign.published",
2387
+ "campaign.sent"
2388
+ ]);
2389
+ if (knownPublishEvents.has(eventType)) {
2390
+ return true;
2391
+ }
2392
+ if (eventType === "content.published" && originType === "newsletter_campaign") {
2393
+ return true;
2394
+ }
2395
+ if ((eventType.includes("newsletter") || eventType.includes("campaign")) && eventType.endsWith(".updated") && (status === "published" || status === "sent")) {
2396
+ return true;
2397
+ }
2398
+ return false;
2399
+ }
2400
+ pickString(source, keys) {
2401
+ if (!source) {
2402
+ return void 0;
2403
+ }
2404
+ for (const key of keys) {
2405
+ const value = source[key];
2406
+ if (typeof value === "string" && value.trim().length > 0) {
2407
+ return value.trim();
2408
+ }
2409
+ }
2410
+ return void 0;
2411
+ }
2412
+ isCachePolicy(value) {
2413
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2414
+ return false;
2415
+ }
2416
+ const record = value;
2417
+ return "ttlSeconds" in record || "tags" in record || "metadata" in record || "skipCache" in record;
2418
+ }
2206
2419
  };
2207
2420
 
2208
2421
  // src/client/site-users-client.ts
@@ -2466,6 +2679,19 @@ var SiteUsersClient = class extends BaseClient {
2466
2679
  csrfToken
2467
2680
  );
2468
2681
  }
2682
+ /**
2683
+ * Create a Stripe Billing Portal session for updating payment methods
2684
+ * @param siteName - The site name
2685
+ * @param returnUrl - URL to redirect back to after portal session
2686
+ * @param csrfToken - CSRF token (required)
2687
+ */
2688
+ async createBillingPortalSession(siteName, returnUrl, csrfToken) {
2689
+ return this.create(
2690
+ this.siteUserEndpoint(siteName, "/users/me/billing-portal"),
2691
+ { return_url: returnUrl },
2692
+ csrfToken
2693
+ );
2694
+ }
2469
2695
  /**
2470
2696
  * Get linked newsletter subscriptions
2471
2697
  * @param siteName - The site name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perspectapi-ts-sdk",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "TypeScript SDK for PerspectAPI - Cloudflare Workers compatible",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -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
  }
@@ -355,6 +355,24 @@ export class SiteUsersClient extends BaseClient {
355
355
  );
356
356
  }
357
357
 
358
+ /**
359
+ * Create a Stripe Billing Portal session for updating payment methods
360
+ * @param siteName - The site name
361
+ * @param returnUrl - URL to redirect back to after portal session
362
+ * @param csrfToken - CSRF token (required)
363
+ */
364
+ async createBillingPortalSession(
365
+ siteName: string,
366
+ returnUrl: string,
367
+ csrfToken?: string
368
+ ): Promise<ApiResponse<{ url: string }>> {
369
+ return this.create<{ return_url: string }, { url: string }>(
370
+ this.siteUserEndpoint(siteName, '/users/me/billing-portal'),
371
+ { return_url: returnUrl },
372
+ csrfToken
373
+ );
374
+ }
375
+
358
376
  /**
359
377
  * Get linked newsletter subscriptions
360
378
  * @param siteName - The site name
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,