Merge pull request #119 from parcelvoy/feat/subscription-preferences-page

adds basic subscription preferences page
This commit is contained in:
Chris Hills 2023-04-11 08:11:04 -05:00 committed by GitHub
commit e47dd7e916
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 919 additions and 2605 deletions

View file

@ -10,7 +10,7 @@ import { random, snakeCase, uuid } from '../utilities'
import App from '../app'
import JourneyProcessJob from './JourneyProcessJob'
import { Database } from '../config/database'
import { Compile } from '../render'
import { compileTemplate } from '../render'
import { logger } from '../config/logger'
export class JourneyUserStep extends Model {
@ -272,7 +272,7 @@ export class JourneyUpdate extends JourneyStep {
if (this.template.trim()) {
let value: any
try {
value = JSON.parse(Compile(this.template, {
value = JSON.parse(compileTemplate(this.template)({
user: user.flatten(),
event: event?.flatten(),
}))

View file

@ -0,0 +1,13 @@
import { HelperOptions } from 'handlebars'
export function ifEquals<T>(
this: T,
left: any,
right: any,
options: HelperOptions,
) {
if (arguments.length !== 3) {
return '' // throw error?
}
return left === right ? options.fn(this) : options.inverse(this)
}

View file

@ -130,7 +130,7 @@ export class TextTemplate extends Template {
}
compile(variables: Variables): CompiledText {
return { text: Render(this.text, variables) }
return { text: Render(this.text ?? '', variables) }
}
validate() {

View file

@ -1,11 +1,12 @@
import Handlebars from 'handlebars'
import * as CommonHelpers from './Helpers/Common'
import * as StrHelpers from './Helpers/String'
import * as NumHelpers from './Helpers/Number'
import * as DateHelpers from './Helpers/Date'
import * as UrlHelpers from './Helpers/Url'
import * as ArrayHelpers from './Helpers/Array'
import { User } from '../users/User'
import { unsubscribeEmailLink } from '../subscriptions/SubscriptionService'
import { preferencesLink, unsubscribeEmailLink } from '../subscriptions/SubscriptionService'
import { clickWrapHtml, openWrapHtml, preheaderWrapHtml } from './LinkService'
import App from '../app'
@ -34,14 +35,15 @@ const loadHelper = (helper: Record<string, any>) => {
}
}
loadHelper(CommonHelpers)
loadHelper(StrHelpers)
loadHelper(NumHelpers)
loadHelper(DateHelpers)
loadHelper(UrlHelpers)
loadHelper(ArrayHelpers)
export const Compile = (template: string, context: Record<string, any> = {}) => {
return Handlebars.compile(template)(context)
export const compileTemplate = <T = any>(template: string) => {
return Handlebars.compile<T>(template)
}
interface WrapParams {
@ -64,17 +66,14 @@ export const Wrap = ({ html, preheader, variables: { user, context } }: WrapPara
}
export default (template: string, { user, event, context }: Variables) => {
const trackingParams = { userId: user.id, campaignId: context?.campaign_id }
console.log('context', {
return compileTemplate(template)({
user: user.flatten(),
event,
context,
unsubscribeEmailUrl: unsubscribeEmailLink(trackingParams),
})
return Compile(template, {
user: user.flatten(),
event,
context,
unsubscribeEmailUrl: unsubscribeEmailLink(trackingParams),
unsubscribeEmailUrl: unsubscribeEmailLink({
userId: user.id,
campaignId: context?.campaign_id,
}),
preferencesUrl: preferencesLink(user.id),
})
}

View file

@ -2,7 +2,7 @@ import jsonpath from 'jsonpath'
import Rule, { AnyJson, Operator } from './Rule'
import { Database } from '../config/database'
import { RuleCheckInput, RuleEvalException } from './RuleEngine'
import { Compile } from '../render'
import { compileTemplate } from '../render'
export const queryValue = <T>(input: RuleCheckInput, rule: Rule, cast: (item: any) => T): T | undefined => {
const inputValue = input[rule.group]
@ -28,7 +28,7 @@ export const compile = <Y>(rule: Rule, cast: (item: AnyJson) => Y): Y => {
throw new RuleEvalException(rule, 'value required for operator')
}
const compiledValue = typeof value === 'string' && value.includes('{')
? Compile(value)
? compileTemplate(value)({})
: value
return cast(compiledValue)
}

View file

@ -2,14 +2,17 @@ import Router from '@koa/router'
import App from '../app'
import { RequestError } from '../core/errors'
import { JSONSchemaType, validate } from '../core/validate'
import Subscription, { SubscriptionParams } from './Subscription'
import { createSubscription, getSubscription, pagedSubscriptions, unsubscribe } from './SubscriptionService'
import Subscription, { SubscriptionParams, SubscriptionState, UserSubscription } from './Subscription'
import { createSubscription, getSubscription, pagedSubscriptions, toggleSubscription, unsubscribe } from './SubscriptionService'
import SubscriptionError from './SubscriptionError'
import { encodedLinkToParts } from '../render/LinkService'
import { ProjectState } from '../auth/AuthMiddleware'
import { extractQueryParams } from '../utilities'
import { decodeHashid, extractQueryParams } from '../utilities'
import { searchParamsSchema } from '../core/searchParams'
import { projectRoleMiddleware } from '../projects/ProjectService'
import { compileTemplate } from '../render'
import { getUser } from '../users/UserRepository'
import { User } from 'users/User'
/**
***
@ -39,17 +42,172 @@ export const emailUnsubscribeSchema: JSONSchemaType<EmailUnsubscribeParams> = {
},
additionalProperties: false,
}
publicRouter.post('/email', async ctx => {
const { user, campaign } = await encodedLinkToParts(ctx.URL)
if (!user || !campaign) throw new RequestError(SubscriptionError.UnsubscribeFailed)
if (!user) throw new RequestError(SubscriptionError.UnsubscribeInvalidUser)
if (!campaign) throw new RequestError(SubscriptionError.UnsubscribeInvalidCampaign)
await unsubscribe(user.id, campaign.subscription_id)
ctx.status = 204
})
/**
***
* User-facing subscription preferences page
***
*/
const preferencesPage = new Router<{
app: App
user?: User
subscriptions?: SubscriptionPreferencesArgs['subscriptions']
}>({
prefix: '/preferences/:encodedUserId',
})
preferencesPage.param('encodedUserId', async (value, ctx, next) => {
const userId = decodeHashid(value)
if (!userId) throw new RequestError(SubscriptionError.UnsubscribeInvalidUser)
const user = await getUser(userId)
if (!user) throw new RequestError(SubscriptionError.UnsubscribeInvalidUser)
ctx.state.user = user
ctx.state.subscriptions = await UserSubscription
.query()
.select('subscriptions.id as id')
.select('subscriptions.name as name')
.select('state')
.join('subscriptions', 'subscription_id', 'subscriptions.id')
.where('user_id', user.id)
.orderBy('subscriptions.name', 'asc')
return await next()
})
interface SubscriptionPreferencesArgs {
url: string
subscriptions: Array<{
id: number
name: string
state: SubscriptionState
}>
showUpdatedMessage?: boolean
}
const subscriptionPreferencesTemplate = compileTemplate<SubscriptionPreferencesArgs>(`
<!DOCTYPE html>
<html lang="en">
<head>
<title>Subscription Preferences</title>
<style>
body {
font-family: 'Inter', 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
font-size: 15px;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main {
margin: 50px auto;
padding: 15px;
max-width: 500px;
}
label {
display: block;
margin-bottom: 10px;
}
input[type="submit"] {
display: inline-block;
padding: 10px 20px;
border-radius: 8px;
background-color: #151c2d;
color: #fff;
font-size: 15px;
border: 0;
cursor: pointer;
margin-top: 15px;
}
.alert-success {
background-color: #d1fadf;
color: #039855;
padding: 10px;
margin: 10px 0;
border-radius: 8px;
}
</style>
</head>
<body>
<main>
{{#if subscriptions}}
<form action="{{url}}" method="post">
<h1>Subscription Preferences</h1>
<p>Choose which notifications you would like to continue to receive.</p>
{{#if showUpdatedMessage}}
<div class="alert-success">
Preferenced Saved Successfully!
</div>
{{/if}}
{{#each subscriptions}}
<label>
<input
type="checkbox"
name="subscriptionIds"
value="{{this.id}}"
{{#ifEquals this.state 1}}checked{{/ifEquals}}
/>
<span>
{{this.name}}
</span>
</label>
{{/each}}
<input type="submit" value="Save Preferences" />
</form>
{{else}}
<div>
You are not subscribed to any notifications.
</div>
{{/if}}
</main>
</body>
</html>
`)
preferencesPage.get('/', async ctx => {
ctx.headers['content-type'] = 'text/html'
ctx.body = subscriptionPreferencesTemplate({
subscriptions: ctx.state.subscriptions ?? [],
url: App.main.env.baseUrl + ctx.URL.pathname,
showUpdatedMessage: ctx.query.u === '1',
})
})
preferencesPage.post('/', async ctx => {
const { subscriptionIds } = ctx.request.body
const ids = (Array.isArray(subscriptionIds) ? subscriptionIds : [subscriptionIds as string])
?.map(Number)
.filter(n => !isNaN(n)) ?? []
for (const sub of ctx.state.subscriptions ?? []) {
await toggleSubscription(
ctx.state.user!.id,
sub.id,
ids.includes(sub.id)
? SubscriptionState.subscribed
: SubscriptionState.unsubscribed,
)
}
return ctx.redirect(App.main.env.baseUrl + ctx.URL.pathname + '?u=1')
})
publicRouter.use(
preferencesPage.routes(),
preferencesPage.allowedMethods(),
)
export { publicRouter }
/**

View file

@ -1,6 +1,18 @@
import { ErrorType } from 'core/errors'
export default {
UnsubscribeFailed: {
message: 'Unable to unsubscribe, either the user or subscription type do not exist!',
code: 4000,
},
}
UnsubscribeInvalidUser: {
message: 'User does not exist!',
code: 4001,
statusCode: 404,
},
UnsubscribeInvalidCampaign: {
message: 'Campaign does not exist!',
code: 4002,
statusCode: 404,
},
} satisfies Record<string, ErrorType>

View file

@ -6,6 +6,8 @@ import { User } from '../users/User'
import { createEvent } from '../users/UserEventRepository'
import { getUser, getUserFromPhone } from '../users/UserRepository'
import Subscription, { SubscriptionParams, SubscriptionState, UserSubscription } from './Subscription'
import App from '../app'
import { combineURLs, encodeHashid } from '../utilities'
export const pagedSubscriptions = async (params: SearchParams, projectId: number) => {
return await Subscription.searchParams(
@ -96,7 +98,11 @@ export const toggleSubscription = async (userId: number, subscriptionId: number,
// If subscription exists, unsubscribe, otherwise subscribe
const previous = await UserSubscription.first(qb => qb.where(condition))
if (previous) {
await UserSubscription.update(qb => qb.where('id', previous.id), { state })
if (previous.state === state) {
return
} else {
await UserSubscription.update(qb => qb.where('id', previous.id), { state })
}
} else {
await UserSubscription.insert({
...condition,
@ -145,3 +151,7 @@ export const subscribeAll = async (user: User): Promise<void> => {
export const unsubscribeEmailLink = (params: TrackedLinkParams): string => {
return paramsToEncodedLink({ ...params, path: 'unsubscribe/email' })
}
export const preferencesLink = (userId: number) => {
return combineURLs([App.main.env.baseUrl, 'unsubscribe/preferences', encodeHashid(userId)])
}

View file

@ -98,7 +98,8 @@ export const partialMatchLocale = (locale1?: string, locale2?: string) => {
export function extractQueryParams<T extends Record<string, any>>(search: URLSearchParams | Record<string, undefined | string | string[]>, schema: JSONSchemaType<T>) {
return validate(schema, Object.entries<JSONSchemaType<any>>(schema.properties).reduce((a, [name, def]) => {
let values: string[]
let values!: string[]
if (search instanceof URLSearchParams) {
values = search.getAll(name)
} else {

3283
package-lock.json generated

File diff suppressed because it is too large Load diff