mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-29 11:56:04 +08:00
Merge pull request #119 from parcelvoy/feat/subscription-preferences-page
adds basic subscription preferences page
This commit is contained in:
commit
e47dd7e916
10 changed files with 919 additions and 2605 deletions
|
@ -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(),
|
||||
}))
|
||||
|
|
13
apps/platform/src/render/Helpers/Common.ts
Normal file
13
apps/platform/src/render/Helpers/Common.ts
Normal 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)
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)])
|
||||
}
|
||||
|
|
|
@ -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
3283
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue