@splendidlabz/third-party 0.1.0 → 1.1.0-beta.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/CHANGELOG.md +18 -0
- package/buttondown.js +74 -31
- package/buttondown.spec.js +123 -0
- package/eslint.config.js +2 -0
- package/package.json +16 -6
- package/postmark.js +7 -21
- package/stripe.js +5 -5
- package/vitest.config.js +23 -0
- package/.eslintrc.cjs +0 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @splendidlabz/third-party
|
|
2
|
+
|
|
3
|
+
## 1.1.0-beta.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Ready for next release
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @splendidlabz/utils@1.5.0-beta.5
|
|
13
|
+
|
|
14
|
+
## 1.0.0
|
|
15
|
+
|
|
16
|
+
### Major Changes
|
|
17
|
+
|
|
18
|
+
- Added a couple of improvements
|
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 =
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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: {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 }, {
|
|
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
|
+
})
|
package/eslint.config.js
ADDED
package/package.json
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@splendidlabz/third-party",
|
|
3
3
|
"prettier": "@splendidlabz/prettier-config",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.1.0-beta.0",
|
|
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
|
-
"
|
|
22
|
+
"@splendidlabz/utils": "1.5.0-beta.5",
|
|
23
|
+
"googleapis": "^148.0.0",
|
|
15
24
|
"http-errors": "^2.0.0",
|
|
16
|
-
"postmark": "^4.0.
|
|
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 =
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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 (!
|
|
84
|
-
bpData.flow_data[flow_type] = { subscription:
|
|
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))
|
package/vitest.config.js
ADDED
|
@@ -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