Skip to content

Commit 7a02c20

Browse files
committed
🎨 add newsletter distribution and subscription
1 parent 745dbba commit 7a02c20

9 files changed

Lines changed: 493 additions & 127 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import soapRequest from 'easy-soap-request'
2+
import * as xml2js from 'xml2js'
3+
import type { LoginResult, NewsDistributionParameters } from '@/types'
4+
5+
//import type { LoginResult, NewsDistributionParameters } from '../../types/index'
6+
7+
const subscriptionUrl = process.env.BRANDMASTER_EMAIL_SUBSCRIPTION_URL || ''
8+
export const authenticationUrl =
9+
process.env.BRANDMASTER_EMAIL_AUTHENTICATION_URL || ''
10+
const clientSecret = process.env.BRANDMASTER_EMAIL_CLIENT_SECRET
11+
const password = process.env.BRANDMASTER_EMAIL_PASSWORD
12+
const apnId = process.env.BRANDMASTER_EMAIL_APN_ID
13+
const otyId = process.env.BRANDMASTER_EMAIL_OTY_ID
14+
const ptlId = process.env.BRANDMASTER_EMAIL_PTL_ID
15+
16+
const sampleHeaders = {
17+
'Content-Type': 'text/xml;charset=UTF-8',
18+
}
19+
const xml = `<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><Authentication___Login xmlns="http://tempuri.org/"><clientSecret>${clientSecret}</clientSecret><userName>SUBSCRIPTIONAPI</userName><password>${password}</password><comId>34</comId><ptlId>${ptlId}</ptlId><otyId>${otyId}</otyId><laeId>1</laeId><apnId>${apnId}</apnId></Authentication___Login></s:Body></s:Envelope>`
20+
const authenticate = async () => {
21+
const { response } = await soapRequest({
22+
url: authenticationUrl,
23+
headers: sampleHeaders,
24+
xml: xml,
25+
timeout: 5000,
26+
})
27+
const { body } = response
28+
let apiSecret = ''
29+
let instId = ''
30+
xml2js.parseString(body, function (err, result) {
31+
if (err != null)
32+
console.error(
33+
'Error while authenticating from Brandmaster : ----------------\n' +
34+
err,
35+
)
36+
if (
37+
parsedError(result, 'could not get apiSecret and instId ') !== undefined
38+
)
39+
return
40+
41+
const soapBody = result['SOAP-ENV:Envelope']['SOAP-ENV:Body']['0']
42+
const loginResult =
43+
soapBody['v1:Authentication___LoginResponse']['0']['v1:Result']['0']
44+
apiSecret = loginResult['v1:apiSecret']['0']
45+
instId = loginResult['v1:instId']['0']
46+
})
47+
return { apiSecret, instId }
48+
}
49+
50+
const createDistributeRequest = async (
51+
loginResult: LoginResult,
52+
parameters: NewsDistributionParameters,
53+
) => {
54+
const envelope = `<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><Subscription___Distribute xmlns="http://tempuri.org/"><clientSecret>${clientSecret}</clientSecret><apiSecret>${loginResult.apiSecret}</apiSecret><instId>${loginResult.instId}</instId><timeStamp>${parameters.timeStamp}</timeStamp><Title><![CDATA[${parameters.title}]]></Title><Ingress><![CDATA[${parameters.ingress}]]></Ingress><newsURL><![CDATA[${parameters.link}]]></newsURL><newsType><![CDATA[${parameters.newsType}]]></newsType><language><![CDATA[${parameters.languageCode}]]></language><additionalParams/></Subscription___Distribute></s:Body></s:Envelope>`
55+
const { response } = await soapRequest({
56+
url: subscriptionUrl,
57+
headers: sampleHeaders,
58+
xml: envelope,
59+
timeout: 5000,
60+
})
61+
xml2js.parseString(response.body, function (err, result) {
62+
if (err != null) {
63+
console.error(
64+
'Error while creating distribute request to Brandmaster : ----------------\n' +
65+
err,
66+
)
67+
response.statusCode = 400
68+
}
69+
const error = parsedError(
70+
result,
71+
'could not distribute newsletter ' +
72+
parameters.link +
73+
' published at ' +
74+
parameters.timeStamp,
75+
)
76+
if (error !== undefined) {
77+
// should trigger mail...
78+
console.log('Newsletter distribution failure', response.body.toString())
79+
// @TODO Move to Sentry
80+
// appInsights.trackEvent({name:"Newsletter distribution failure"},{message:error})
81+
response.statusCode = 400
82+
}
83+
})
84+
85+
return response.statusCode === 200
86+
}
87+
88+
export const distributeOld = async (parameters: NewsDistributionParameters) => {
89+
const loginResult = await authenticate()
90+
if (loginResult.apiSecret !== '' && loginResult.instId !== '') {
91+
return createDistributeRequest(loginResult, parameters)
92+
}
93+
return false
94+
}
95+
96+
const parsedError = (result: any, prefix: string) => {
97+
const soapBody = result['SOAP-ENV:Envelope']['SOAP-ENV:Body']['0']
98+
if (soapBody['SOAP-ENV:Fault'] !== undefined) {
99+
const error = soapBody['SOAP-ENV:Fault']['0']['faultstring']
100+
console.error(
101+
Date() + ' : Newsletter Failure Error: ' + prefix + '\n' + error,
102+
)
103+
return error
104+
}
105+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
2+
import axios from 'axios'
3+
import type { NextRequest } from 'next/server'
4+
import { domain, languages } from '@/languageConfig'
5+
import { distributeOld } from './old-distribution'
6+
7+
const SANITY_API_TOKEN = process.env.SANITY_API_TOKEN || ''
8+
const SLACK_NEWSLETTER_WEBHOOK_URL = process.env.SLACK_NEWSLETTER_WEBHOOK_URL
9+
const MAKE_NEWSLETTER_API_BASE_URL = process.env.MAKE_NEWSLETTER_API_BASE_URL
10+
const MAKE_NEWSLETTER_ID_EN = process.env.MAKE_NEWSLETTER_ID_EN
11+
const MAKE_NEWSLETTER_ID_NO = process.env.MAKE_NEWSLETTER_ID_NO
12+
const MAKE_API_USER = process.env.MAKE_API_USERID || ''
13+
const MAKE_API_KEY = process.env.MAKE_API_KEY || ''
14+
15+
// NEeded?
16+
//Next.js will by default parse the body, which can lead to invalid signatures
17+
// https://nextjs.org/docs/api-routes/api-middlewares#custom-config
18+
/* export const config = {
19+
api: {
20+
bodyParser: false,
21+
},
22+
} */
23+
24+
async function sendSlackNotification(message: string): Promise<void> {
25+
if (!SLACK_NEWSLETTER_WEBHOOK_URL) return
26+
27+
try {
28+
await fetch(SLACK_NEWSLETTER_WEBHOOK_URL, {
29+
method: 'POST',
30+
headers: {
31+
'Content-Type': 'application/json',
32+
},
33+
body: JSON.stringify({ text: message }),
34+
})
35+
console.log('Slack notification sent sucessfully!')
36+
} catch (error) {
37+
console.error('Failed to send Slack notification:', error)
38+
}
39+
}
40+
41+
function getDateWithMs(): string {
42+
const date = new Date()
43+
const milliseconds = date.getMilliseconds().toString().padStart(3, '0')
44+
const formatter = new Intl.DateTimeFormat('en-GB', {
45+
year: '2-digit',
46+
month: '2-digit',
47+
day: '2-digit',
48+
hour: '2-digit',
49+
minute: '2-digit',
50+
second: '2-digit',
51+
hour12: false,
52+
})
53+
const dateTime = formatter.format(date)
54+
55+
return `${dateTime}:${milliseconds}`
56+
}
57+
58+
export type NewsDistributionParameters = {
59+
title: string
60+
link: string
61+
languageCode?: string
62+
}
63+
64+
const newsletterApi = axios.create({
65+
baseURL: MAKE_NEWSLETTER_API_BASE_URL,
66+
headers: {
67+
'Content-Type': 'application/json',
68+
Authorization: `Basic ${Buffer.from(`${MAKE_API_USER}:${MAKE_API_KEY}`).toString('base64')}`,
69+
},
70+
})
71+
72+
/**
73+
* Distribute a newsletter
74+
*/
75+
export const distribute = async (
76+
newsDistributionParameters: NewsDistributionParameters,
77+
) => {
78+
try {
79+
const url = `${MAKE_NEWSLETTER_API_BASE_URL}/recurring_actions/${
80+
newsDistributionParameters.languageCode === 'no'
81+
? MAKE_NEWSLETTER_ID_NO
82+
: MAKE_NEWSLETTER_ID_EN
83+
}/trigger`
84+
85+
const response = await newsletterApi.post(url)
86+
return response.status === 200
87+
} catch (error: any) {
88+
console.error('❌ Error in distribute:', {
89+
message: error.message,
90+
responseData: error.response?.data,
91+
responseStatus: error.response?.status,
92+
requestHeaders: error.config?.headers,
93+
})
94+
95+
return false
96+
}
97+
}
98+
99+
interface DistributionResult {
100+
success: boolean
101+
message: string
102+
}
103+
104+
async function distributeWithRetry(
105+
newsDistributionParameters: NewsDistributionParameters,
106+
oldNewsDistributionParameters: any,
107+
attempt = 1,
108+
): Promise<DistributionResult> {
109+
let res: DistributionResult = {
110+
success: false,
111+
message: `Initial state: Distribution not started for *${newsDistributionParameters.title}* (${newsDistributionParameters.link})`,
112+
}
113+
114+
const date = getDateWithMs()
115+
116+
try {
117+
//For migration period just log.
118+
const isNewSuccessful = await distribute(newsDistributionParameters)
119+
console.log(`New distribution was successful: ${isNewSuccessful}`)
120+
121+
const isSuccessful = await distributeOld(oldNewsDistributionParameters)
122+
123+
if (!isSuccessful) throw new Error('Distribution was unsuccessful.')
124+
res = {
125+
success: true,
126+
message: 'Newsletter sent successfully!',
127+
}
128+
129+
const message = `${date} :white_check_mark: *${newsDistributionParameters.title}*: Successfully distributed (${newsDistributionParameters.link})`
130+
await sendSlackNotification(message)
131+
} catch (error) {
132+
const errorMessage =
133+
error instanceof Error ? error.message : 'An unknown error occurred'
134+
const message = `${date} :x: *${newsDistributionParameters.title}*: Distribution of (${newsDistributionParameters.link}) failed after attempt #${attempt}: ${errorMessage}`
135+
await sendSlackNotification(message)
136+
137+
if (attempt < 3) {
138+
console.log(`Retrying... Attempt ${attempt + 1}`)
139+
return distributeWithRetry(newsDistributionParameters, attempt + 1)
140+
}
141+
res = {
142+
success: false,
143+
message: `Distribution failed for: ${newsDistributionParameters.title} (${newsDistributionParameters.link})`,
144+
}
145+
}
146+
147+
return res
148+
}
149+
150+
export async function POST(req: NextRequest) {
151+
console.log('Sending newsletter... ')
152+
console.log('Datetime: ' + new Date())
153+
const signature = req.headers.get(SIGNATURE_HEADER_NAME)
154+
const body = await req.text()
155+
156+
if (!isValidSignature(body, signature || '', SANITY_API_TOKEN)) {
157+
console.log(req, 'Unauthorized request: Newsletter Distribution Endpoint')
158+
return new Response(
159+
JSON.stringify({ success: false, msg: 'Unauthorized!' }),
160+
{ status: 401 },
161+
)
162+
}
163+
const data = JSON.parse(body)
164+
const locale =
165+
languages.find(lang => lang.name === data.languageCode)?.locale || 'en'
166+
167+
//To be removed after migration period
168+
const oldNewsDistributionParameters: any = {
169+
timeStamp: data.timeStamp,
170+
title: data.title,
171+
ingress: data.ingress,
172+
link: `${domain}/${locale}${data.link}`,
173+
newsType: data.newsType,
174+
languageCode: locale,
175+
}
176+
// END
177+
178+
const newsDistributionParameters: NewsDistributionParameters = {
179+
title: data.title,
180+
link: `${domain}/${locale}${data.link}`,
181+
languageCode: locale,
182+
}
183+
console.log('Newsletter link: ', newsDistributionParameters.link)
184+
185+
await distributeWithRetry(
186+
newsDistributionParameters,
187+
oldNewsDistributionParameters,
188+
)
189+
.then(result => {
190+
if (result.success) {
191+
return new Response(JSON.stringify({ message: result.message }), {
192+
status: 200,
193+
})
194+
}
195+
return new Response(JSON.stringify({ message: result.message }), {
196+
status: 400,
197+
})
198+
})
199+
.catch(error => {
200+
console.log(error)
201+
return new Response(
202+
JSON.stringify({ message: 'Internal server error' }),
203+
{
204+
status: 500,
205+
},
206+
)
207+
})
208+
}

0 commit comments

Comments
 (0)