@splendidlabz/third-party 1.0.0 → 1.1.0-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @splendidlabz/third-party
2
2
 
3
+ ## 1.1.0-beta.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @splendidlabz/utils@1.5.0-beta.6
9
+
10
+ ## 1.1.0-beta.0
11
+
12
+ ### Minor Changes
13
+
14
+ - Ready for next release
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+ - @splendidlabz/utils@1.5.0-beta.5
20
+
3
21
  ## 1.0.0
4
22
 
5
23
  ### Major Changes
package/buttondown.js CHANGED
@@ -1,13 +1,15 @@
1
- import { omitEmpty } from '@splendidlabz/utils'
1
+ import { omitEmpty, uniqueArray } from '@splendidlabz/utils'
2
+ import 'dotenv/config'
2
3
  import { createZlFetch, reject } from 'zl-fetch'
3
4
 
4
- const token = import.meta.env.BUTTONDOWN_KEY
5
+ const token = process.env.BUTTONDOWN_KEY
5
6
 
6
7
  const BASE_URL = 'https://api.buttondown.email/v1'
7
8
  const buttonDown = createZlFetch(BASE_URL, {
8
9
  headers: {
9
10
  Accept: 'application/json',
10
11
  Authorization: `Token ${token}`,
12
+ 'X-API-Version': '2025-06-01',
11
13
  },
12
14
  returnError: true,
13
15
  debug: true,
@@ -19,31 +21,40 @@ export async function getSubscriber(email) {
19
21
  else return null
20
22
  }
21
23
 
22
- const DEFAULT_SUBSCRIBER_OPTIONS = {
23
- notes: '',
24
- metadata: {},
25
- tags: [],
26
- referrer_url: '',
27
- utm_campagin: '',
28
- utm_medium: '',
29
- utm_source: '',
30
- referring_subscriber_id: '',
31
- subscriber_type: 'regular',
32
- }
24
+ // const DEFAULT_SUBSCRIBER_OPTIONS = {
25
+ // // type: 'regular', // subscribes immediately, no double-opt-in
26
+ // metadata: {},
27
+ // tags: [],
28
+ // // referrer_url: '',
29
+ // // utm_campagin: '',
30
+ // // utm_medium: '',
31
+ // // utm_source: '',
32
+ // // referring_subscriber_id: '',
33
+ // // notes: '',
34
+ // }
33
35
 
34
36
  // Creates a subscriber
35
- // Notes:
36
- // - Button down throws an error if the email is invalid. Though this only occurs after using fake emails for many times. Shouldn't be a problem to ignore.
37
- export async function createSubscriber(email, props = {}) {
38
- const data = omitEmpty({ ...DEFAULT_SUBSCRIBER_OPTIONS, ...props })
39
- if (props.double_opt_in === true) data.subscriber_type = 'unactivated'
37
+ // Button down docs: https://docs.buttondown.com/api-subscribers-create
38
+ export async function createSubscriber(
39
+ email,
40
+ props = {},
41
+ { double_opt_in = false } = {},
42
+ ) {
43
+ const data = omitEmpty(props)
44
+ if (double_opt_in) data.type = 'regular'
45
+
40
46
  const { response, error } = await buttonDown.post('/subscribers', {
41
- body: { email, ...data },
47
+ body: {
48
+ email_address: email,
49
+ ...data,
50
+ },
42
51
  })
43
52
 
44
53
  if (response) return response.body
45
54
  if (error) {
46
- if (error.code === 'email_invalid') return // Swallow this error
55
+ console.log(error)
56
+ // Button down throws this error if the email is invalid. Though this only occurs after using fake emails for many times. Shouldn't be a problem to ignore.
57
+ if (error.code === 'email_invalid') return
47
58
  return reject(error)
48
59
  }
49
60
  }
@@ -53,26 +64,29 @@ export async function createSubscriber(email, props = {}) {
53
64
  // Notes:
54
65
  // - Buttondown throws an error if subscriber doesn't exist.
55
66
  // - It's not possible to change a user's email with this API.
56
- export async function updateSubscriber(email, props = {}, options = {}) {
67
+ export async function updateSubscriber(
68
+ email,
69
+ props = {},
70
+ { overwrite = false } = {},
71
+ ) {
57
72
  const {
58
73
  metadata: incomingMetadata = {},
59
74
  tags: incomingTags = [],
60
75
  ...rest
61
76
  } = props
62
- const { mergeTags = true, mergeMetadata = true } = options
63
77
 
64
78
  const sub = await getSubscriber(email)
65
79
  if (!sub) return reject({ status: 400, message: 'Subscriber not found' })
66
80
 
67
- // Determine whether to merge or overwrite tags and metadata
68
- const { tags, metadata } = sub
69
- const newTags = mergeTags
70
- ? Array.from(new Set(tags, incomingTags))
71
- : incomingTags
72
- const newMetadata = mergeMetadata
73
- ? Object.assign(metadata, incomingMetadata)
74
- : incomingMetadata
81
+ let newTags = []
82
+ if (overwrite) newTags = incomingTags
83
+ else newTags = uniqueArray([...sub.tags, ...incomingTags])
75
84
 
85
+ let newMetadata = {}
86
+ if (overwrite) newMetadata = incomingMetadata
87
+ else newMetadata = Object.assign(sub.metadata, incomingMetadata)
88
+
89
+ // Omit empty values so they won't hit button down. So the original values won't be overwritten.
76
90
  const data = omitEmpty({
77
91
  ...rest,
78
92
  tags: newTags,
@@ -93,9 +107,38 @@ export async function upsertSubscriber(email, props = {}) {
93
107
  else return createSubscriber(email, props)
94
108
  }
95
109
 
110
+ // To unsubscribe -> Set subscriber type to unsubscribed
111
+ export async function unsubscribe(email) {
112
+ return updateSubscriber(email, { type: 'unsubscribed' })
113
+ }
114
+
96
115
  export async function removeTagsFromSubscriber(email, tags = []) {
97
116
  const sub = await getSubscriber(email)
98
117
  if (!sub) return reject({ status: 400, message: 'Subscriber not found' })
99
118
  const newTags = sub.tags.filter(tag => !tags.includes(tag))
100
- return updateSubscriber(email, { tags: newTags }, { mergeTags: false })
119
+ return updateSubscriber(email, { tags: newTags }, { overwrite: true })
120
+ }
121
+
122
+ export async function deleteSubscriber(email) {
123
+ // Cannot use zlFetch here because we'll get a JSON error in handling the response. Default to using fetch for now, unless maybe we go fix zlFetch. But not now.
124
+ await fetch(`${BASE_URL}/subscribers/${email}`, {
125
+ method: 'DELETE',
126
+ headers: {
127
+ Authorization: `Token ${token}`,
128
+ },
129
+ })
130
+ }
131
+
132
+ // This doesn't work yet, but gonna focus on building other things first.
133
+ export async function deleteTags(tags) {
134
+ return Promise.all(
135
+ tags.map(tag =>
136
+ fetch(`${BASE_URL}/tags/${tag}`, {
137
+ method: 'DELETE',
138
+ headers: {
139
+ Authorization: `Token ${token}`,
140
+ },
141
+ }),
142
+ ),
143
+ )
101
144
  }
@@ -0,0 +1,123 @@
1
+ import 'dotenv/config'
2
+ import { test as base, describe, expect } from 'vitest'
3
+ import {
4
+ createSubscriber,
5
+ deleteSubscriber,
6
+ deleteTags,
7
+ getSubscriber,
8
+ removeTagsFromSubscriber,
9
+ updateSubscriber,
10
+ } from './buttondown.js'
11
+
12
+ // Test email that we'll use for all tests
13
+ if (!import.meta.env.BUTTONDOWN_KEY)
14
+ throw new Error('BUTTONDOWN_KEY environment variable is required')
15
+
16
+ const DEFAULT_DATA = {
17
+ tags: ['test', 'test2'],
18
+ metadata: { first_name: 'Name' },
19
+ }
20
+
21
+ // Create test with custom fixtures
22
+ const test = base.extend({
23
+ // eslint-disable-next-line no-empty-pattern
24
+ email: async ({}, use) => {
25
+ const email = `test-user-${Date.now()}@example.com`
26
+ await use(email)
27
+ // Cleanup after test
28
+ await deleteSubscriber(email)
29
+ await deleteTags([...DEFAULT_DATA.tags, 'test3'])
30
+ },
31
+ })
32
+
33
+ describe('Create Subscriber', () => {
34
+ test('Creates a new subscriber', async ({ email }) => {
35
+ const result = await createSubscriber(email, DEFAULT_DATA)
36
+
37
+ expect(result.email_address).toBe(email)
38
+ expect(result.metadata.first_name).toBe(DEFAULT_DATA.metadata.first_name)
39
+ expect(result.tags).toContain(DEFAULT_DATA.tags[0])
40
+ expect(result.tags).toContain(DEFAULT_DATA.tags[1])
41
+ expect(result.type).toBe('unactivated')
42
+ })
43
+
44
+ test('Bypasses double-opt-in', async ({ email }) => {
45
+ const result = await createSubscriber(email, DEFAULT_DATA, {
46
+ double_opt_in: true,
47
+ })
48
+ expect(result.type).toBe('regular')
49
+ })
50
+ })
51
+
52
+ describe('Get Subscriber', () => {
53
+ test('Gets a subscriber', async ({ email }) => {
54
+ await createSubscriber(email, DEFAULT_DATA)
55
+ const result = await getSubscriber(email)
56
+ expect(result.email_address).toBe(email)
57
+ expect(result.metadata.first_name).toBe(DEFAULT_DATA.metadata.first_name)
58
+ expect(result.tags).toContain(DEFAULT_DATA.tags[0])
59
+ expect(result.tags).toContain(DEFAULT_DATA.tags[1])
60
+ })
61
+
62
+ test('Null if no subscriber', async () => {
63
+ const result = await getSubscriber('nonexistent@example.com')
64
+ expect(result).toBeNull()
65
+ })
66
+ })
67
+
68
+ describe('Update Subscriber', () => {
69
+ test('updates subscriber tags and metadata', async ({ email }) => {
70
+ await createSubscriber(email, DEFAULT_DATA)
71
+
72
+ const result = await updateSubscriber(email, {
73
+ tags: ['test3'],
74
+ metadata: { last_name: 'Last' },
75
+ })
76
+
77
+ expect(result.tags).toContain('test')
78
+ expect(result.tags).toContain('test2')
79
+ expect(result.tags).toContain('test3')
80
+ expect(result.metadata.last_name).toBe('Last')
81
+ expect(result.metadata.first_name).toBe(DEFAULT_DATA.metadata.first_name)
82
+ })
83
+
84
+ test('Overwrites tags', async ({ email }) => {
85
+ await createSubscriber(email, DEFAULT_DATA)
86
+ const result = await updateSubscriber(
87
+ email,
88
+ { tags: ['test3'] },
89
+ { overwrite: true },
90
+ )
91
+
92
+ expect(result.tags).toContain('test3')
93
+ expect(result.tags).not.toContain('test')
94
+ expect(result.tags).not.toContain('test2')
95
+ expect(result.metadata.first_name).toBe(DEFAULT_DATA.metadata.first_name)
96
+ })
97
+
98
+ test('Overwrites metadata', async ({ email }) => {
99
+ await createSubscriber(email, DEFAULT_DATA)
100
+ const result = await updateSubscriber(
101
+ email,
102
+ { metadata: { last_name: 'Last' } },
103
+ { overwrite: true },
104
+ )
105
+
106
+ expect(result.tags).toContain('test')
107
+ expect(result.tags).toContain('test2')
108
+ expect(result.metadata.first_name).not.toBe(
109
+ DEFAULT_DATA.metadata.first_name,
110
+ )
111
+ expect(result.metadata.last_name).toBe('Last')
112
+ })
113
+ })
114
+
115
+ describe('Remove Tags from Subscriber', () => {
116
+ test('Retains other tags', async ({ email }) => {
117
+ await createSubscriber(email, DEFAULT_DATA)
118
+ const result = await removeTagsFromSubscriber(email, ['test2'])
119
+
120
+ expect(result.tags).toContain('test')
121
+ expect(result.tags).not.toContain('test2')
122
+ })
123
+ })
@@ -0,0 +1,2 @@
1
+ import config from '@splendidlabz/eslint-config'
2
+ export default config
package/package.json CHANGED
@@ -1,23 +1,33 @@
1
1
  {
2
2
  "name": "@splendidlabz/third-party",
3
3
  "prettier": "@splendidlabz/prettier-config",
4
- "version": "1.0.0",
4
+ "version": "1.1.0-beta.1",
5
5
  "description": "",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
8
  "sideEffects": false,
9
+ "exports": {
10
+ "./google": "./google.js",
11
+ "./postmark": "./postmark.js",
12
+ "./buttondown": "./buttondown.js",
13
+ "./stripe": "./stripe.js"
14
+ },
9
15
  "scripts": {
10
- "lint": "eslint . --fix"
16
+ "lint": "eslint . --fix",
17
+ "test": "vitest",
18
+ "test:watch": "vitest --watch"
11
19
  },
12
20
  "author": "Zell Liew <zellwk@gmail.com>",
13
21
  "dependencies": {
14
- "googleapis": "^140.0.1",
22
+ "@splendidlabz/utils": "1.5.0-beta.6",
23
+ "googleapis": "^148.0.0",
15
24
  "http-errors": "^2.0.0",
16
- "postmark": "^4.0.4",
25
+ "postmark": "^4.0.5",
26
+ "vitest": "^3.1.4",
17
27
  "zl-fetch": "^6.0.6"
18
28
  },
19
29
  "devDependencies": {
20
- "@splendidlabz/eslint-config": "*",
21
- "@splendidlabz/prettier-config": "*"
30
+ "@splendidlabz/eslint-config": "2.0.0-alpha.1",
31
+ "@splendidlabz/prettier-config": "1.1.0-beta.2"
22
32
  }
23
33
  }
package/postmark.js CHANGED
@@ -1,20 +1,22 @@
1
1
  import postmark from 'postmark'
2
2
 
3
- const POSTMARK_KEY = import.meta.env.POSTMARK_KEY
3
+ const POSTMARK_KEY = process.env.POSTMARK_KEY
4
4
  const DEFAULT_OPTIONS = {
5
5
  MessageStream: 'outbound', // outbound | broadcast
6
6
  }
7
7
 
8
8
  const client = new postmark.ServerClient(POSTMARK_KEY)
9
9
 
10
- // Allow for default options
11
10
  export default function Postmark(options) {
12
11
  const baseOpts = Object.assign({}, DEFAULT_OPTIONS, options)
13
12
 
14
13
  return {
15
- // Sends email with Postmark
16
- sendEmail(newOptions) {
17
- const opts = Object.assign({}, baseOpts, newOptions)
14
+ constructEmailAddress(name, email) {
15
+ return `${name} <${email}>`
16
+ },
17
+
18
+ sendEmail(options) {
19
+ const opts = Object.assign({}, baseOpts, options)
18
20
  const { from, to, replyTo, subject, html, text, ...rest } = opts
19
21
 
20
22
  return client.sendEmail({
@@ -29,19 +31,3 @@ export default function Postmark(options) {
29
31
  },
30
32
  }
31
33
  }
32
-
33
- // Allow for direct usage
34
- export async function sendEmail(newOptions) {
35
- const opts = Object.assign({}, DEFAULT_OPTIONS, newOptions)
36
- const { from, to, replyTo, subject, html, text, ...rest } = opts
37
-
38
- return client.sendEmail({
39
- From: from,
40
- To: to,
41
- ReplyTo: replyTo,
42
- Subject: subject,
43
- HTMLBody: html,
44
- TextBody: text,
45
- ...rest,
46
- })
47
- }
package/stripe.js CHANGED
@@ -49,14 +49,14 @@ export async function verifyStripeWebhookEvent({
49
49
  * @param {string} props.customer - Customer ID
50
50
  * @param {string} props.return_url - Return URL to be directed to
51
51
  * @param {FlowType} [props.flow_type] - Flow type. Defaults to null.
52
- * @param {string} [props.subscriptionId] - Subscription ID to be updated or cancelled.
52
+ * @param {string} [props.subscription_id] - Subscription ID to be updated or cancelled.
53
53
  * @returns {Promise<Object>} The created billing portal session.
54
54
  * @example
55
55
  * const session = await createBillingPortalSession(stripe, {
56
56
  * customer: 'cus_123456789',
57
57
  * return_url: 'https://example.com/return',
58
58
  * flow_type: 'subscription_update',
59
- * subscriptionId: 'sub_123456789'
59
+ * subscription_id: 'sub_123456789'
60
60
  * });
61
61
  */
62
62
  export async function createBillingPortalSession(stripe, props = {}) {
@@ -64,7 +64,7 @@ export async function createBillingPortalSession(stripe, props = {}) {
64
64
  customer,
65
65
  return_url,
66
66
  flow_type = null,
67
- subscriptionId = null,
67
+ subscription_id = null,
68
68
  } = props
69
69
 
70
70
  const bpData = {
@@ -80,8 +80,8 @@ export async function createBillingPortalSession(stripe, props = {}) {
80
80
  flow_type === 'subscription_update' ||
81
81
  flow_type === 'subscription_cancel'
82
82
  ) {
83
- if (!subscriptionId) throw new Error('Subscription ID is required')
84
- bpData.flow_data[flow_type] = { subscription: subscriptionId }
83
+ if (!subscription_id) throw new Error('Subscription ID is required')
84
+ bpData.flow_data[flow_type] = { subscription: subscription_id }
85
85
  }
86
86
 
87
87
  return stripe.billingPortal.sessions.create(omitEmpty(bpData))
@@ -0,0 +1,23 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ // Increase timeout for API tests
6
+ testTimeout: 10000,
7
+
8
+ // Better error output
9
+ onConsoleLog(log, type) {
10
+ console.log(`[${type}] ${log}`)
11
+ return false
12
+ },
13
+
14
+ // Show test progress
15
+ progress: true,
16
+
17
+ // Better diff output
18
+ diffLimit: 1000,
19
+
20
+ // Show test location in output
21
+ location: true,
22
+ },
23
+ })
package/.eslintrc.cjs DELETED
@@ -1,3 +0,0 @@
1
- module.exports = {
2
- extends: ['@splendidlabz/eslint-config'],
3
- }