@nuxt-customer-portal/core 0.6.1 → 0.8.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @nuxt-customer-portal/core
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 175854d: Support validated conditional sections in email texts and HTML templates, preserve conditional tags in the visual editor, and let administrators preview purchase emails with or without downloads.
8
+
9
+ ### Patch Changes
10
+
11
+ - 28bb7af: Add public purchase file permalinks, confirmation email download links, external thank-you page delivery integration, and database records for public and portal download requests.
12
+
13
+ ## 0.7.0
14
+
3
15
  ## 0.6.1
4
16
 
5
17
  ## 0.6.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/core",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'core',
3
- version: '0.6.1',
3
+ version: '0.8.0',
4
4
  source: '@nuxt-customer-portal/core',
5
5
  dependsOn: [],
6
6
  schema: './server/db/schema/index.ts',
@@ -1,3 +1,4 @@
1
+ import { renderEmailTemplate, validateEmailTemplateSyntax } from '../../shared/email-template'
1
2
  import { isPortalDemo, rejectDemoAction } from './demo'
2
3
  import { createHash } from 'node:crypto'
3
4
  import { eq } from 'drizzle-orm'
@@ -173,34 +174,25 @@ export const decryptPortalEmailSecret = (value: string) => {
173
174
  }
174
175
  }
175
176
 
176
- const replacePlaceholders = (input: string, values: Record<string, string>, allowed: Set<string>) =>
177
- input.replace(placeholderPattern, (_match, key: string) => {
178
- if (!allowed.has(key)) {
179
- throw new Error(`Unknown email placeholder: ${key}`)
180
- }
181
- if (!(key in values)) {
182
- throw new Error(`Missing email placeholder value: ${key}`)
183
- }
184
- return values[key]!
185
- })
186
-
187
177
  export const validatePortalEmailTemplate = (template: string) => {
188
178
  const found = [...template.matchAll(placeholderPattern)].map((match) => match[1]!)
189
179
  if (!found.includes('body')) {
190
180
  throw createError({ statusCode: 422, message: 'The email template must contain {{body}}' })
191
181
  }
192
- const unknown = found.find((key) => !TEMPLATE_PLACEHOLDERS.has(key))
193
- if (unknown) {
194
- throw createError({ statusCode: 422, message: `Unknown template placeholder: ${unknown}` })
182
+ try {
183
+ validateEmailTemplateSyntax(template, TEMPLATE_PLACEHOLDERS, 'template')
184
+ } catch (error) {
185
+ throw createError({ statusCode: 422, message: error instanceof Error ? error.message : 'Invalid email template' })
195
186
  }
196
187
  }
197
188
 
198
189
  export const validatePortalEmailText = (definition: PortalEmailDefinition, text: PortalEmailText) => {
199
190
  const allowed = new Set([...definition.placeholders.map((item) => item.key), 'brand_name'])
200
191
  for (const value of [text.subject, text.body, text.footer ?? '']) {
201
- const unknown = [...value.matchAll(placeholderPattern)].map((match) => match[1]!).find((key) => !allowed.has(key))
202
- if (unknown) {
203
- throw createError({ statusCode: 422, message: `Unknown ${definition.id} placeholder: ${unknown}` })
192
+ try {
193
+ validateEmailTemplateSyntax(value, allowed, definition.id)
194
+ } catch (error) {
195
+ throw createError({ statusCode: 422, message: error instanceof Error ? error.message : 'Invalid email text' })
204
196
  }
205
197
  }
206
198
  }
@@ -336,9 +328,9 @@ export const renderPortalEmail = async (input: {
336
328
  const brandName = branding.brandName
337
329
  const messageValues = { ...input.values, brand_name: brandName }
338
330
  const allowed = new Set([...input.definition.placeholders.map((item) => item.key), 'brand_name'])
339
- const subject = replacePlaceholders(text.subject, messageValues, allowed)
340
- const bodySource = replacePlaceholders(text.body, messageValues, allowed)
341
- const footerSource = replacePlaceholders(text.footer ?? '', messageValues, allowed)
331
+ const subject = renderEmailTemplate(text.subject, messageValues, allowed)
332
+ const bodySource = renderEmailTemplate(text.body, messageValues, allowed)
333
+ const footerSource = renderEmailTemplate(text.footer ?? '', messageValues, allowed)
342
334
  const body = renderPortalEmailMarkdown(bodySource)
343
335
  const footer = renderPortalEmailMarkdown(footerSource)
344
336
  const htmlTemplate = input.htmlTemplate ?? settings.htmlTemplate
@@ -348,7 +340,7 @@ export const renderPortalEmail = async (input: {
348
340
  input.inlineBrandAssets && htmlTemplate.includes(`{{${key}}}`)
349
341
  ? inlineBrandAsset(value, brandName, `portal-${key.replaceAll('_', '-')}`, inlineAttachments)
350
342
  : renderBrandAsset(value, brandName)
351
- const html = replacePlaceholders(
343
+ const html = renderEmailTemplate(
352
344
  htmlTemplate,
353
345
  {
354
346
  subject: escapeHtml(subject),
@@ -1,7 +1,8 @@
1
1
  export function isPublicStoreRoute(method: string, pathname: string) {
2
2
  return (
3
3
  (method === 'GET' &&
4
- (/^\/api\/store\/v1\/products(?:\/[^/]+)?$/.test(pathname) ||
4
+ (/^\/api\/store\/purchases\/[^/]+(?:\/files\/[^/]+)?$/.test(pathname) ||
5
+ /^\/api\/store\/v1\/products(?:\/[^/]+)?$/.test(pathname) ||
5
6
  /^\/api\/store\/(product|media)\/[^/]+$/.test(pathname) ||
6
7
  /^\/api\/store\/planning\/[^/]+\/availability$/.test(pathname) ||
7
8
  pathname === '/api/store/planning/hold' ||
@@ -0,0 +1,90 @@
1
+ type EmailNode =
2
+ | { type: 'text'; value: string }
3
+ | { type: 'value'; key: string }
4
+ | { type: 'if'; key: string; yes: EmailNode[]; no: EmailNode[] }
5
+
6
+ function parseEmailTemplate(source: string, allowed: Set<string>, label = 'email'): EmailNode[] {
7
+ const root: EmailNode[] = []
8
+ const stack: Array<{ node: Extract<EmailNode, { type: 'if' }>; parent: EmailNode[]; otherwise: boolean }> = []
9
+ let current = root
10
+ let offset = 0
11
+ const text = (value: string) => {
12
+ if (/{{\s*(?:#if\b|\/if\b|else\b)/.test(value)) {
13
+ throw new Error('Incomplete email conditional tag')
14
+ }
15
+ if (value) {
16
+ current.push({ type: 'text', value })
17
+ }
18
+ }
19
+ const checkKey = (key: string) => {
20
+ if (!allowed.has(key)) {
21
+ throw new Error(`Unknown ${label} placeholder: ${key}`)
22
+ }
23
+ }
24
+ for (const match of source.matchAll(/{{([\s\S]*?)}}/g)) {
25
+ text(source.slice(offset, match.index))
26
+ offset = match.index + match[0].length
27
+ const token = match[1]!.trim()
28
+ const condition = token.match(/^#if\s+([a-z0-9_]+)$/i)
29
+ if (condition) {
30
+ checkKey(condition[1]!)
31
+ if (stack.length >= 20) {
32
+ throw new Error('Email conditionals may nest at most 20 levels')
33
+ }
34
+ const node: Extract<EmailNode, { type: 'if' }> = { type: 'if', key: condition[1]!, yes: [], no: [] }
35
+ current.push(node)
36
+ stack.push({ node, parent: current, otherwise: false })
37
+ current = node.yes
38
+ } else if (token === 'else') {
39
+ const frame = stack.at(-1)
40
+ if (!frame || frame.otherwise) {
41
+ throw new Error('Unexpected {{else}} in email template')
42
+ }
43
+ frame.otherwise = true
44
+ current = frame.node.no
45
+ } else if (token === '/if') {
46
+ const frame = stack.pop()
47
+ if (!frame) {
48
+ throw new Error('Unexpected {{/if}} in email template')
49
+ }
50
+ current = frame.parent
51
+ } else if (/^[a-z0-9_]+$/i.test(token)) {
52
+ checkKey(token)
53
+ current.push({ type: 'value', key: token })
54
+ } else {
55
+ throw new Error(`Unsupported email template tag: {{${token}}}`)
56
+ }
57
+ }
58
+ text(source.slice(offset))
59
+ if (stack.length) {
60
+ throw new Error('Missing {{/if}} in email template')
61
+ }
62
+ return root
63
+ }
64
+
65
+ export function validateEmailTemplateSyntax(source: string, allowed: Set<string>, label = 'email') {
66
+ parseEmailTemplate(source, allowed, label)
67
+ }
68
+
69
+ export function renderEmailTemplate(source: string, values: Record<string, string>, allowed: Set<string>) {
70
+ const nodes = parseEmailTemplate(source, allowed)
71
+ const value = (key: string) => {
72
+ if (!Object.hasOwn(values, key)) {
73
+ throw new Error(`Missing email placeholder value: ${key}`)
74
+ }
75
+ return values[key]!
76
+ }
77
+ const render = (items: EmailNode[]): string =>
78
+ items
79
+ .map((node) => {
80
+ if (node.type === 'text') {
81
+ return node.value
82
+ }
83
+ if (node.type === 'value') {
84
+ return value(node.key)
85
+ }
86
+ return render(value(node.key).trim() ? node.yes : node.no)
87
+ })
88
+ .join('')
89
+ return render(nodes)
90
+ }