@nuxt-customer-portal/core 0.3.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 +6 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/app/composables/useAutoPagination.ts +30 -0
- package/app/composables/useOrganization.ts +117 -0
- package/app/composables/usePaginatedResource.ts +167 -0
- package/app/composables/usePortalFeatures.ts +74 -0
- package/app/composables/usePortalSession.ts +20 -0
- package/app/composables/useUserStore.ts +460 -0
- package/app/middleware/auth.global.ts +32 -0
- package/app/plugins/auth.ts +28 -0
- package/app/plugins/portal-core-feature.ts +3 -0
- package/app/types/index.d.ts +75 -0
- package/app/utils/auth-client.ts +31 -0
- package/app/utils/permission-utils.ts +21 -0
- package/app/utils/random.ts +9 -0
- package/i18n/locales/en.json +81 -0
- package/i18n/locales/nl.json +81 -0
- package/migrations/0000_baseline.sql +119 -0
- package/migrations/0001_organization_types.sql +12 -0
- package/migrations/0002_invitation_created_at.sql +1 -0
- package/migrations/0003_system_admin_role.sql +5 -0
- package/migrations/0004_provider_organization_type.sql +16 -0
- package/migrations/0005_portal_email_settings.sql +15 -0
- package/migrations/meta/0000_snapshot.json +880 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +55 -0
- package/package.json +66 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/auth/[...].ts +18 -0
- package/server/api/auth/permissions.get.ts +64 -0
- package/server/api/notifications.ts +294 -0
- package/server/db/core-schema.ts +1 -0
- package/server/db/schema/auth-schema.ts +198 -0
- package/server/db/schema/index.ts +1 -0
- package/server/middleware/api-docs-auth.ts +26 -0
- package/server/middleware/auth.ts +61 -0
- package/server/portal.ts +212 -0
- package/server/routes/api-docs/openapi.json.get.ts +22 -0
- package/server/utils/admin-email-allowlist.ts +12 -0
- package/server/utils/auth.ts +326 -0
- package/server/utils/business-hooks.ts +28 -0
- package/server/utils/db.ts +18 -0
- package/server/utils/email-template.html +105 -0
- package/server/utils/email-texts.ts +110 -0
- package/server/utils/email.ts +37 -0
- package/server/utils/invitation-management.ts +29 -0
- package/server/utils/openapi-auth.ts +67 -0
- package/server/utils/openapi-contracts.ts +129 -0
- package/server/utils/openapi-order.ts +136 -0
- package/server/utils/permissions.ts +179 -0
- package/server/utils/portal-email.ts +424 -0
- package/server/utils/query-builder.ts +270 -0
- package/server/utils/route-meta.ts +28 -0
- package/shared/core-feature.ts +152 -0
- package/shared/feature-registry.ts +62 -0
- package/shared/invitation-validation.ts +8 -0
- package/shared/organization-avatar.ts +11 -0
- package/shared/permissions.ts +26 -0
- package/shared/portal-session.ts +17 -0
- package/shared/types/feature.ts +130 -0
- package/shared/types/index.ts +40 -0
- package/shared/types/sections/api.ts +134 -0
- package/shared/types/sections/auth.ts +71 -0
- package/shared/types/sections/database.ts +86 -0
- package/shared/types/sections/queryBuilder.ts +31 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { eq } from 'drizzle-orm'
|
|
3
|
+
import { createError } from 'h3'
|
|
4
|
+
import { Resend } from 'resend'
|
|
5
|
+
import type {
|
|
6
|
+
PortalEmailDefinition,
|
|
7
|
+
PortalEmailLocale,
|
|
8
|
+
PortalEmailText
|
|
9
|
+
} from '@nuxt-customer-portal/core/shared/types/feature'
|
|
10
|
+
import { portalEmailSettings } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
|
|
11
|
+
import { db, pool } from './db'
|
|
12
|
+
|
|
13
|
+
export type PortalEmailTextOverrides = Record<string, Partial<PortalEmailText>>
|
|
14
|
+
|
|
15
|
+
export interface PortalEmailSettingsInput {
|
|
16
|
+
apiKey?: string
|
|
17
|
+
fromName: string
|
|
18
|
+
fromEmail: string
|
|
19
|
+
defaultLocale: PortalEmailLocale
|
|
20
|
+
htmlTemplate: string | null
|
|
21
|
+
textOverrides: PortalEmailTextOverrides
|
|
22
|
+
definitions?: Array<{ moduleId: string; definition: PortalEmailDefinition }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PortalEmailAttachment {
|
|
26
|
+
filename: string
|
|
27
|
+
content: Buffer
|
|
28
|
+
contentType?: string
|
|
29
|
+
contentId?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TEMPLATE_PLACEHOLDERS = new Set([
|
|
33
|
+
'subject',
|
|
34
|
+
'brand_name',
|
|
35
|
+
'brand_logo',
|
|
36
|
+
'brand_logo_light',
|
|
37
|
+
'brand_logo_dark',
|
|
38
|
+
'brand_icon_light',
|
|
39
|
+
'brand_icon_dark',
|
|
40
|
+
'brand_tagline',
|
|
41
|
+
'brand_primary_color',
|
|
42
|
+
'body',
|
|
43
|
+
'footer',
|
|
44
|
+
'current_year'
|
|
45
|
+
])
|
|
46
|
+
const placeholderPattern = /{{\s*([a-z0-9_]+)\s*}}/gi
|
|
47
|
+
const senderPattern = /^\s*([^<>]+?)\s*<\s*([^<>]+)\s*>\s*$/
|
|
48
|
+
|
|
49
|
+
const parseSender = (value = '') => {
|
|
50
|
+
const match = value.match(senderPattern)
|
|
51
|
+
return match ? { fromName: match[1]!.trim(), fromEmail: match[2]!.trim() } : { fromName: '', fromEmail: value.trim() }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type PortalEmailBranding = {
|
|
55
|
+
brandName: string
|
|
56
|
+
brandTagline: string
|
|
57
|
+
brandLogoLight: string
|
|
58
|
+
brandLogoDark: string
|
|
59
|
+
brandIconLight: string
|
|
60
|
+
brandIconDark: string
|
|
61
|
+
primaryColor: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const resolvePortalEmailBranding = async (): Promise<PortalEmailBranding> => {
|
|
65
|
+
const config = useRuntimeConfig()
|
|
66
|
+
const fallback = {
|
|
67
|
+
brandName: String(config.portalEmail?.brandName || 'Nuxt Customer Portal'),
|
|
68
|
+
brandTagline: String(config.portalEmail?.brandTagline || ''),
|
|
69
|
+
brandLogoLight: String(config.portalEmail?.brandLogoLight || config.portalEmail?.brandLogo || ''),
|
|
70
|
+
brandLogoDark: String(config.portalEmail?.brandLogoDark || config.portalEmail?.brandLogo || ''),
|
|
71
|
+
brandIconLight: String(config.portalEmail?.brandIconLight || ''),
|
|
72
|
+
brandIconDark: String(config.portalEmail?.brandIconDark || ''),
|
|
73
|
+
primaryColor: /^#[0-9a-f]{6}$/i.test(String(config.portalEmail?.primaryColor))
|
|
74
|
+
? String(config.portalEmail.primaryColor)
|
|
75
|
+
: '#0ea5e9'
|
|
76
|
+
}
|
|
77
|
+
if (config.portalEmail?.brandingSource !== 'portal-settings') {
|
|
78
|
+
return fallback
|
|
79
|
+
}
|
|
80
|
+
const result = await pool.query<{
|
|
81
|
+
portal_name: string | null
|
|
82
|
+
tagline: string | null
|
|
83
|
+
logo_light: string | null
|
|
84
|
+
logo_dark: string | null
|
|
85
|
+
mark_light: string | null
|
|
86
|
+
mark_dark: string | null
|
|
87
|
+
primary_light: string | null
|
|
88
|
+
}>(`SELECT
|
|
89
|
+
settings->'branding'->>'portalName' AS portal_name,
|
|
90
|
+
settings->'branding'->>'tagline' AS tagline,
|
|
91
|
+
settings->'branding'->>'logoLight' AS logo_light,
|
|
92
|
+
settings->'branding'->>'logoDark' AS logo_dark,
|
|
93
|
+
settings->'branding'->>'markLight' AS mark_light,
|
|
94
|
+
settings->'branding'->>'markDark' AS mark_dark,
|
|
95
|
+
settings->'appearance'->>'primaryLight' AS primary_light
|
|
96
|
+
FROM saas_configuration.portal_settings WHERE id=true`)
|
|
97
|
+
const branding = result.rows[0]
|
|
98
|
+
return {
|
|
99
|
+
brandName: branding?.portal_name || fallback.brandName,
|
|
100
|
+
brandTagline: branding?.tagline || fallback.brandTagline,
|
|
101
|
+
brandLogoLight: branding?.logo_light || fallback.brandLogoLight,
|
|
102
|
+
brandLogoDark: branding?.logo_dark || fallback.brandLogoDark,
|
|
103
|
+
brandIconLight: branding?.mark_light || fallback.brandIconLight,
|
|
104
|
+
brandIconDark: branding?.mark_dark || fallback.brandIconDark,
|
|
105
|
+
primaryColor: /^#[0-9a-f]{6}$/i.test(branding?.primary_light || '')
|
|
106
|
+
? branding!.primary_light!
|
|
107
|
+
: fallback.primaryColor
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const escapeHtml = (value: string) =>
|
|
112
|
+
value.replace(
|
|
113
|
+
/[&<>"']/g,
|
|
114
|
+
(character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const renderBrandAsset = (value: string, brandName: string, sourceOverride?: string) => {
|
|
118
|
+
if (!value || !/^(data:image\/(png|jpeg|webp);base64,|https?:\/\/)/i.test(value)) {
|
|
119
|
+
return ''
|
|
120
|
+
}
|
|
121
|
+
return `<img src="${escapeHtml(sourceOverride || value)}" alt="${escapeHtml(brandName)}" style="display:block;max-width:220px;max-height:72px;margin:0 auto 12px" />`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const inlineBrandAsset = (
|
|
125
|
+
value: string,
|
|
126
|
+
brandName: string,
|
|
127
|
+
contentId: string,
|
|
128
|
+
attachments: PortalEmailAttachment[]
|
|
129
|
+
) => {
|
|
130
|
+
const match = value.match(/^data:image\/(png|jpeg|webp);base64,([a-z0-9+/=\s]+)$/i)
|
|
131
|
+
if (!match) {
|
|
132
|
+
return renderBrandAsset(value, brandName)
|
|
133
|
+
}
|
|
134
|
+
const subtype = match[1]!.toLowerCase()
|
|
135
|
+
const extension = subtype === 'jpeg' ? 'jpg' : subtype
|
|
136
|
+
attachments.push({
|
|
137
|
+
filename: `${contentId}.${extension}`,
|
|
138
|
+
content: Buffer.from(match[2]!.replace(/\s/g, ''), 'base64'),
|
|
139
|
+
contentType: `image/${subtype}`,
|
|
140
|
+
contentId
|
|
141
|
+
})
|
|
142
|
+
return renderBrandAsset(value, brandName, `cid:${contentId}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const encryptionKey = () => {
|
|
146
|
+
const value = process.env.PORTAL_EMAIL_ENCRYPTION_KEY
|
|
147
|
+
if (!value) {
|
|
148
|
+
throw new Error('PORTAL_EMAIL_ENCRYPTION_KEY is required to store email credentials')
|
|
149
|
+
}
|
|
150
|
+
return createHash('sha256').update(value).digest()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const encryptPortalEmailSecret = (value: string) => {
|
|
154
|
+
const iv = randomBytes(12)
|
|
155
|
+
const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv)
|
|
156
|
+
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
|
|
157
|
+
return [
|
|
158
|
+
'v1',
|
|
159
|
+
iv.toString('base64url'),
|
|
160
|
+
cipher.getAuthTag().toString('base64url'),
|
|
161
|
+
encrypted.toString('base64url')
|
|
162
|
+
].join('.')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const decryptPortalEmailSecret = (value: string) => {
|
|
166
|
+
const [version, iv, tag, encrypted] = value.split('.')
|
|
167
|
+
if (version !== 'v1' || !iv || !tag || !encrypted) {
|
|
168
|
+
throw new Error('Stored email credential has an invalid format')
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(iv, 'base64url'))
|
|
172
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64url'))
|
|
173
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8')
|
|
174
|
+
} catch {
|
|
175
|
+
throw new Error('Stored email credential could not be decrypted')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const replacePlaceholders = (input: string, values: Record<string, string>, allowed: Set<string>) =>
|
|
180
|
+
input.replace(placeholderPattern, (_match, key: string) => {
|
|
181
|
+
if (!allowed.has(key)) {
|
|
182
|
+
throw new Error(`Unknown email placeholder: ${key}`)
|
|
183
|
+
}
|
|
184
|
+
if (!(key in values)) {
|
|
185
|
+
throw new Error(`Missing email placeholder value: ${key}`)
|
|
186
|
+
}
|
|
187
|
+
return values[key]!
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
export const validatePortalEmailTemplate = (template: string) => {
|
|
191
|
+
const found = [...template.matchAll(placeholderPattern)].map((match) => match[1]!)
|
|
192
|
+
if (!found.includes('body')) {
|
|
193
|
+
throw createError({ statusCode: 422, message: 'The email template must contain {{body}}' })
|
|
194
|
+
}
|
|
195
|
+
const unknown = found.find((key) => !TEMPLATE_PLACEHOLDERS.has(key))
|
|
196
|
+
if (unknown) {
|
|
197
|
+
throw createError({ statusCode: 422, message: `Unknown template placeholder: ${unknown}` })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const validatePortalEmailText = (definition: PortalEmailDefinition, text: PortalEmailText) => {
|
|
202
|
+
const allowed = new Set([...definition.placeholders.map((item) => item.key), 'brand_name'])
|
|
203
|
+
for (const value of [text.subject, text.body, text.footer ?? '']) {
|
|
204
|
+
const unknown = [...value.matchAll(placeholderPattern)].map((match) => match[1]!).find((key) => !allowed.has(key))
|
|
205
|
+
if (unknown) {
|
|
206
|
+
throw createError({ statusCode: 422, message: `Unknown ${definition.id} placeholder: ${unknown}` })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const defaultTemplate = async () => {
|
|
212
|
+
const config = useRuntimeConfig()
|
|
213
|
+
const storage = config.portalEmail?.templateStorage || 'assets:portal-core'
|
|
214
|
+
const template = await useStorage(storage).getItem<string>('email-template.html')
|
|
215
|
+
if (!template) {
|
|
216
|
+
throw new Error('Default portal email template could not be loaded')
|
|
217
|
+
}
|
|
218
|
+
return template
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const readRow = async () => {
|
|
222
|
+
const [row] = await db.select().from(portalEmailSettings).where(eq(portalEmailSettings.id, true)).limit(1)
|
|
223
|
+
return row ?? null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const getPortalEmailSettings = async () => {
|
|
227
|
+
const row = await readRow()
|
|
228
|
+
const projectTemplate = await defaultTemplate()
|
|
229
|
+
const environmentSender = parseSender(process.env.RESEND_FROM_EMAIL)
|
|
230
|
+
return {
|
|
231
|
+
provider: 'RESEND' as const,
|
|
232
|
+
configured: Boolean(row?.encryptedApiKey || process.env.RESEND_API_KEY),
|
|
233
|
+
keyLastFour: row?.keyLastFour ?? (process.env.RESEND_API_KEY?.slice(-4) || null),
|
|
234
|
+
fromName: row?.fromName ?? environmentSender.fromName,
|
|
235
|
+
fromEmail: row?.fromEmail ?? environmentSender.fromEmail,
|
|
236
|
+
defaultLocale: row?.defaultLocale === 'nl' ? ('nl' as const) : ('en' as const),
|
|
237
|
+
htmlTemplate: row?.htmlTemplate?.trim() || projectTemplate,
|
|
238
|
+
usingProjectTemplate: !row?.htmlTemplate?.trim(),
|
|
239
|
+
textOverrides: (row?.textOverrides ?? {}) as PortalEmailTextOverrides,
|
|
240
|
+
updatedAt: row?.updatedAt.toISOString() ?? null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const savePortalEmailSettings = async (userId: string, input: PortalEmailSettingsInput) => {
|
|
245
|
+
const template = input.htmlTemplate?.trim() || null
|
|
246
|
+
if (template) {
|
|
247
|
+
validatePortalEmailTemplate(template)
|
|
248
|
+
}
|
|
249
|
+
for (const item of input.definitions ?? []) {
|
|
250
|
+
for (const locale of ['en', 'nl'] as const) {
|
|
251
|
+
const override = input.textOverrides[`${item.moduleId}.${item.definition.id}.${locale}`]
|
|
252
|
+
if (override) {
|
|
253
|
+
validatePortalEmailText(item.definition, { ...item.definition.defaults[locale], ...override })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const key = input.apiKey?.trim()
|
|
258
|
+
const existing = await readRow()
|
|
259
|
+
const values = {
|
|
260
|
+
provider: 'RESEND',
|
|
261
|
+
encryptedApiKey: key ? encryptPortalEmailSecret(key) : (existing?.encryptedApiKey ?? null),
|
|
262
|
+
keyFingerprint: key ? createHash('sha256').update(key).digest('hex') : (existing?.keyFingerprint ?? null),
|
|
263
|
+
keyLastFour: key ? key.slice(-4) : (existing?.keyLastFour ?? null),
|
|
264
|
+
fromName: input.fromName.trim() || null,
|
|
265
|
+
fromEmail: input.fromEmail.trim().toLowerCase() || null,
|
|
266
|
+
defaultLocale: input.defaultLocale,
|
|
267
|
+
htmlTemplate: template,
|
|
268
|
+
textOverrides: input.textOverrides,
|
|
269
|
+
configuredById: userId,
|
|
270
|
+
updatedAt: new Date()
|
|
271
|
+
}
|
|
272
|
+
await db
|
|
273
|
+
.insert(portalEmailSettings)
|
|
274
|
+
.values({ id: true, ...values })
|
|
275
|
+
.onConflictDoUpdate({
|
|
276
|
+
target: portalEmailSettings.id,
|
|
277
|
+
set: values
|
|
278
|
+
})
|
|
279
|
+
return getPortalEmailSettings()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export const resetPortalEmailTemplate = async (userId: string) => {
|
|
283
|
+
const current = await getPortalEmailSettings()
|
|
284
|
+
return savePortalEmailSettings(userId, {
|
|
285
|
+
fromName: current.fromName,
|
|
286
|
+
fromEmail: current.fromEmail,
|
|
287
|
+
defaultLocale: current.defaultLocale,
|
|
288
|
+
htmlTemplate: null,
|
|
289
|
+
textOverrides: current.textOverrides
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const providerConfiguration = async () => {
|
|
294
|
+
const row = await readRow()
|
|
295
|
+
const apiKey = row?.encryptedApiKey ? decryptPortalEmailSecret(row.encryptedApiKey) : process.env.RESEND_API_KEY
|
|
296
|
+
const environmentSender = parseSender(process.env.RESEND_FROM_EMAIL)
|
|
297
|
+
const fromEmail = row?.fromEmail || environmentSender.fromEmail
|
|
298
|
+
if (!apiKey || !fromEmail) {
|
|
299
|
+
throw new Error('Portal email provider is not configured')
|
|
300
|
+
}
|
|
301
|
+
return { apiKey, fromEmail, fromName: row?.fromName || environmentSender.fromName }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const getPortalEmailProviderStatus = async () => {
|
|
305
|
+
const settings = await getPortalEmailSettings()
|
|
306
|
+
if (!settings.configured) {
|
|
307
|
+
return { ...settings, verifiedDomains: [] as string[] }
|
|
308
|
+
}
|
|
309
|
+
const provider = await providerConfiguration()
|
|
310
|
+
const { data, error } = await new Resend(provider.apiKey).domains.list({ limit: 100 })
|
|
311
|
+
if (error) {
|
|
312
|
+
throw new Error(error.message)
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
...settings,
|
|
316
|
+
verifiedDomains: data.data
|
|
317
|
+
.filter((item) => item.status === 'verified' && item.capabilities.sending === 'enabled')
|
|
318
|
+
.map((item) => item.name.toLowerCase())
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export const renderPortalEmail = async (input: {
|
|
323
|
+
moduleId: string
|
|
324
|
+
definition: PortalEmailDefinition
|
|
325
|
+
locale?: string
|
|
326
|
+
values: Record<string, string>
|
|
327
|
+
text?: PortalEmailText
|
|
328
|
+
htmlTemplate?: string
|
|
329
|
+
inlineBrandAssets?: boolean
|
|
330
|
+
}) => {
|
|
331
|
+
const [settings, branding] = await Promise.all([getPortalEmailSettings(), resolvePortalEmailBranding()])
|
|
332
|
+
const locale: PortalEmailLocale = input.locale === 'nl' ? 'nl' : input.locale === 'en' ? 'en' : settings.defaultLocale
|
|
333
|
+
const key = `${input.moduleId}.${input.definition.id}.${locale}`
|
|
334
|
+
const text = { ...input.definition.defaults[locale], ...settings.textOverrides[key], ...input.text }
|
|
335
|
+
validatePortalEmailText(input.definition, text)
|
|
336
|
+
const brandName = branding.brandName
|
|
337
|
+
const messageValues = { ...input.values, brand_name: brandName }
|
|
338
|
+
const allowed = new Set([...input.definition.placeholders.map((item) => item.key), 'brand_name'])
|
|
339
|
+
const subject = replacePlaceholders(text.subject, messageValues, allowed)
|
|
340
|
+
const body = replacePlaceholders(text.body, messageValues, allowed)
|
|
341
|
+
const footer = replacePlaceholders(text.footer ?? '', messageValues, allowed)
|
|
342
|
+
const htmlTemplate = input.htmlTemplate ?? settings.htmlTemplate
|
|
343
|
+
validatePortalEmailTemplate(htmlTemplate)
|
|
344
|
+
const inlineAttachments: PortalEmailAttachment[] = []
|
|
345
|
+
const brandAsset = (value: string, key: string) =>
|
|
346
|
+
input.inlineBrandAssets && htmlTemplate.includes(`{{${key}}}`)
|
|
347
|
+
? inlineBrandAsset(value, brandName, `portal-${key.replaceAll('_', '-')}`, inlineAttachments)
|
|
348
|
+
: renderBrandAsset(value, brandName)
|
|
349
|
+
const html = replacePlaceholders(
|
|
350
|
+
htmlTemplate,
|
|
351
|
+
{
|
|
352
|
+
subject: escapeHtml(subject),
|
|
353
|
+
brand_name: escapeHtml(brandName),
|
|
354
|
+
brand_logo: brandAsset(branding.brandLogoLight || branding.brandLogoDark, 'brand_logo'),
|
|
355
|
+
brand_logo_light: brandAsset(branding.brandLogoLight, 'brand_logo_light'),
|
|
356
|
+
brand_logo_dark: brandAsset(branding.brandLogoDark, 'brand_logo_dark'),
|
|
357
|
+
brand_icon_light: brandAsset(branding.brandIconLight, 'brand_icon_light'),
|
|
358
|
+
brand_icon_dark: brandAsset(branding.brandIconDark, 'brand_icon_dark'),
|
|
359
|
+
brand_tagline: escapeHtml(branding.brandTagline),
|
|
360
|
+
brand_primary_color: branding.primaryColor,
|
|
361
|
+
body,
|
|
362
|
+
footer,
|
|
363
|
+
current_year: String(new Date().getFullYear())
|
|
364
|
+
},
|
|
365
|
+
TEMPLATE_PLACEHOLDERS
|
|
366
|
+
)
|
|
367
|
+
return {
|
|
368
|
+
locale,
|
|
369
|
+
subject,
|
|
370
|
+
body,
|
|
371
|
+
footer,
|
|
372
|
+
html,
|
|
373
|
+
inlineAttachments,
|
|
374
|
+
text: `${body.replace(/<[^>]+>/g, ' ')}\n\n${footer.replace(/<[^>]+>/g, ' ')}`
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const sendPortalEmail = async (input: {
|
|
379
|
+
moduleId: string
|
|
380
|
+
definition: PortalEmailDefinition
|
|
381
|
+
locale?: string
|
|
382
|
+
values: Record<string, string>
|
|
383
|
+
to: string
|
|
384
|
+
cc?: string[]
|
|
385
|
+
text?: PortalEmailText
|
|
386
|
+
htmlTemplate?: string
|
|
387
|
+
fromEmail?: string
|
|
388
|
+
fromName?: string
|
|
389
|
+
attachments?: PortalEmailAttachment[]
|
|
390
|
+
idempotencyKey?: string
|
|
391
|
+
}) => {
|
|
392
|
+
const [rendered, provider] = await Promise.all([
|
|
393
|
+
renderPortalEmail({ ...input, inlineBrandAssets: true }),
|
|
394
|
+
providerConfiguration()
|
|
395
|
+
])
|
|
396
|
+
const fromEmail = input.fromEmail || provider.fromEmail
|
|
397
|
+
const fromName = input.fromName || provider.fromName
|
|
398
|
+
const from = fromName ? `${fromName.replace(/[<>\r\n]/g, '')} <${fromEmail}>` : fromEmail
|
|
399
|
+
const { data, error } = await new Resend(provider.apiKey).emails.send(
|
|
400
|
+
{
|
|
401
|
+
from,
|
|
402
|
+
to: [input.to],
|
|
403
|
+
cc: input.cc?.length ? input.cc : undefined,
|
|
404
|
+
subject: rendered.subject,
|
|
405
|
+
html: rendered.html,
|
|
406
|
+
text: rendered.text,
|
|
407
|
+
attachments: [...rendered.inlineAttachments, ...(input.attachments ?? [])]
|
|
408
|
+
},
|
|
409
|
+
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined
|
|
410
|
+
)
|
|
411
|
+
if (error) {
|
|
412
|
+
throw new Error(error.message)
|
|
413
|
+
}
|
|
414
|
+
return { ...data, rendered }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export const retrievePortalEmail = async (providerMessageId: string) => {
|
|
418
|
+
const provider = await providerConfiguration()
|
|
419
|
+
const { data, error } = await new Resend(provider.apiKey).emails.get(providerMessageId)
|
|
420
|
+
if (error) {
|
|
421
|
+
throw new Error(error.message)
|
|
422
|
+
}
|
|
423
|
+
return data
|
|
424
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { QueryInput, FilterOperator as FilterOperatorType } from '@nuxt-customer-portal/core/shared/types/index'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { SQL, SQLWrapper, AnyColumn } from 'drizzle-orm'
|
|
4
|
+
import { and, eq, ne, gt, lt, gte, lte, ilike, inArray, notInArray, asc, desc } from 'drizzle-orm'
|
|
5
|
+
|
|
6
|
+
// Schema for FilterOperator - now using z.enum with actual values
|
|
7
|
+
export const FilterOperatorSchema = z.enum([
|
|
8
|
+
'eq',
|
|
9
|
+
'neq',
|
|
10
|
+
'gt',
|
|
11
|
+
'lt',
|
|
12
|
+
'gte',
|
|
13
|
+
'lte',
|
|
14
|
+
'contains',
|
|
15
|
+
'startsWith',
|
|
16
|
+
'endsWith',
|
|
17
|
+
'in',
|
|
18
|
+
'notIn'
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
// Schema for a single filter object
|
|
22
|
+
export const FilterSchema = z.object({
|
|
23
|
+
field: z.string(),
|
|
24
|
+
operator: FilterOperatorSchema,
|
|
25
|
+
// Basic JSON value types. For more complex scenarios (e.g. Date), this might need refinement.
|
|
26
|
+
value: z.union([
|
|
27
|
+
z.string(),
|
|
28
|
+
z.number(),
|
|
29
|
+
z.boolean(),
|
|
30
|
+
z.array(z.union([z.string(), z.number(), z.boolean()])),
|
|
31
|
+
z.null()
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Define Zod schema for the Sort object, matching QueryInput's Sort interface
|
|
36
|
+
export const SortSchema = z.object({
|
|
37
|
+
field: z.string(),
|
|
38
|
+
direction: z.enum(['asc', 'desc'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Base schema for common query parameters, now including a generic 'filters' array
|
|
42
|
+
export const BaseQuerySchema = z.object({
|
|
43
|
+
take: z.preprocess((val) => (val ? Number(val) : undefined), z.number().int().min(1).optional()),
|
|
44
|
+
skip: z.preprocess((val) => (val ? Number(val) : undefined), z.number().int().min(0).optional()),
|
|
45
|
+
sortField: z.string().optional(),
|
|
46
|
+
sortDirection: z.enum(['asc', 'desc']).optional(),
|
|
47
|
+
filters: z.preprocess((val) => {
|
|
48
|
+
if (typeof val === 'string') {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(val)
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return val
|
|
56
|
+
}, z.array(FilterSchema).optional())
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Type for a function that resolves a field path to a Drizzle column reference.
|
|
61
|
+
* This allows the query builder to work with any table schema.
|
|
62
|
+
*/
|
|
63
|
+
export type FieldResolver = (fieldPath: string) => AnyColumn | SQLWrapper | undefined
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a default field resolver from a Drizzle table object.
|
|
67
|
+
* This automatically maps field names to table columns.
|
|
68
|
+
*
|
|
69
|
+
* @param table - The Drizzle table object (e.g., userTable)
|
|
70
|
+
* @returns A FieldResolver function that resolves field names to columns
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* const fieldResolver = createFieldResolver(userTable)
|
|
75
|
+
* const { where } = buildDrizzleQuery(queryInput, fieldResolver)
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function createFieldResolver(table: unknown): FieldResolver {
|
|
79
|
+
return (fieldPath: string): AnyColumn | SQLWrapper | undefined => {
|
|
80
|
+
// Type assertion: Drizzle tables have column properties accessible by string keys
|
|
81
|
+
const tableRecord = table as Record<string, unknown>
|
|
82
|
+
const column = tableRecord[fieldPath]
|
|
83
|
+
|
|
84
|
+
// Type guard to ensure it's a valid column (has column-like properties)
|
|
85
|
+
if (
|
|
86
|
+
column &&
|
|
87
|
+
typeof column === 'object' &&
|
|
88
|
+
column !== null &&
|
|
89
|
+
('name' in column || 'getSQL' in column || 'table' in column)
|
|
90
|
+
) {
|
|
91
|
+
return column as AnyColumn | SQLWrapper
|
|
92
|
+
}
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Creates a Drizzle SQL condition from a filter.
|
|
99
|
+
* For nested fields (e.g., "relation.field"), you'll need to handle joins separately.
|
|
100
|
+
* This function supports flat fields only.
|
|
101
|
+
*
|
|
102
|
+
* @param fieldResolver - Function to resolve field paths to column references
|
|
103
|
+
* @param fieldPath - The field path (e.g., "name", "email", "createdAt")
|
|
104
|
+
* @param operator - The filter operator
|
|
105
|
+
* @param value - The filter value
|
|
106
|
+
* @returns A Drizzle SQL condition
|
|
107
|
+
*/
|
|
108
|
+
function createFilterCondition(
|
|
109
|
+
fieldResolver: FieldResolver,
|
|
110
|
+
fieldPath: string,
|
|
111
|
+
operator: FilterOperatorType,
|
|
112
|
+
value: unknown
|
|
113
|
+
): SQL | undefined {
|
|
114
|
+
const column = fieldResolver(fieldPath)
|
|
115
|
+
if (!column) {
|
|
116
|
+
console.warn(`Field "${fieldPath}" could not be resolved to a column`)
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Type guard to ensure column is valid
|
|
121
|
+
if (typeof column !== 'object' || column === null) {
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Handle null values
|
|
126
|
+
if (value === null || value === undefined) {
|
|
127
|
+
if (operator === 'eq') {
|
|
128
|
+
return eq(column, null)
|
|
129
|
+
}
|
|
130
|
+
if (operator === 'neq') {
|
|
131
|
+
return ne(column, null)
|
|
132
|
+
}
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
switch (operator) {
|
|
137
|
+
case 'eq':
|
|
138
|
+
return eq(column, value)
|
|
139
|
+
case 'neq':
|
|
140
|
+
return ne(column, value)
|
|
141
|
+
case 'gt':
|
|
142
|
+
return gt(column, value)
|
|
143
|
+
case 'lt':
|
|
144
|
+
return lt(column, value)
|
|
145
|
+
case 'gte':
|
|
146
|
+
return gte(column, value)
|
|
147
|
+
case 'lte':
|
|
148
|
+
return lte(column, value)
|
|
149
|
+
case 'contains':
|
|
150
|
+
return ilike(column as AnyColumn, `%${String(value)}%`)
|
|
151
|
+
case 'startsWith':
|
|
152
|
+
return ilike(column as AnyColumn, `${String(value)}%`)
|
|
153
|
+
case 'endsWith':
|
|
154
|
+
return ilike(column as AnyColumn, `%${String(value)}`)
|
|
155
|
+
case 'in':
|
|
156
|
+
if (!Array.isArray(value)) {
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
return inArray(column, value)
|
|
160
|
+
case 'notIn':
|
|
161
|
+
if (!Array.isArray(value)) {
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
return notInArray(column, value)
|
|
165
|
+
default:
|
|
166
|
+
return undefined
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Query builder result containing Drizzle-compatible query conditions
|
|
172
|
+
*/
|
|
173
|
+
export interface DrizzleQueryResult {
|
|
174
|
+
/** Drizzle SQL where condition (can be used with .where()) */
|
|
175
|
+
where: SQL | undefined
|
|
176
|
+
/** Drizzle orderBy expression (can be used with .orderBy()) */
|
|
177
|
+
orderBy: SQL | undefined
|
|
178
|
+
/** Limit for pagination */
|
|
179
|
+
limit: number | undefined
|
|
180
|
+
/** Offset for pagination */
|
|
181
|
+
offset: number | undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Builds Drizzle ORM query conditions from a QueryInput object.
|
|
186
|
+
*
|
|
187
|
+
* @param queryInput - The QueryInput object containing dynamic filters, sorting, and pagination.
|
|
188
|
+
* @param tableOrResolver - Either a Drizzle table object (e.g., userTable) or a FieldResolver function.
|
|
189
|
+
* If a table is provided, a field resolver will be automatically created.
|
|
190
|
+
* @param baseWhere - Optional base where condition to combine with filters (using AND).
|
|
191
|
+
* @returns An object with `where`, `orderBy`, `limit`, and `offset` suitable for Drizzle queries.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* // Using a table (simpler)
|
|
196
|
+
* const { where, orderBy, limit, offset } = buildDrizzleQuery(
|
|
197
|
+
* { filters: [{ field: 'email', operator: 'contains', value: 'example' }] },
|
|
198
|
+
* userTable
|
|
199
|
+
* )
|
|
200
|
+
*
|
|
201
|
+
* // Using a custom field resolver (more control)
|
|
202
|
+
* const { where, orderBy, limit, offset } = buildDrizzleQuery(
|
|
203
|
+
* { filters: [{ field: 'email', operator: 'contains', value: 'example' }] },
|
|
204
|
+
* (field) => userTable[field]
|
|
205
|
+
* )
|
|
206
|
+
*
|
|
207
|
+
* let query = db.select().from(userTable)
|
|
208
|
+
* if (where) query = query.where(where)
|
|
209
|
+
* if (orderBy) query = query.orderBy(orderBy)
|
|
210
|
+
* if (limit) query = query.limit(limit)
|
|
211
|
+
* if (offset) query = query.offset(offset)
|
|
212
|
+
* const users = await query
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export function buildDrizzleQuery(
|
|
216
|
+
queryInput: QueryInput,
|
|
217
|
+
tableOrResolver: unknown | FieldResolver,
|
|
218
|
+
baseWhere?: SQL
|
|
219
|
+
): DrizzleQueryResult {
|
|
220
|
+
// Determine if tableOrResolver is a function (FieldResolver) or a table object
|
|
221
|
+
const fieldResolver: FieldResolver =
|
|
222
|
+
typeof tableOrResolver === 'function' ? (tableOrResolver as FieldResolver) : createFieldResolver(tableOrResolver)
|
|
223
|
+
// 1. Build where conditions from filters
|
|
224
|
+
const filterConditions: SQL[] = []
|
|
225
|
+
|
|
226
|
+
if (queryInput.filters?.length) {
|
|
227
|
+
for (const filter of queryInput.filters) {
|
|
228
|
+
const condition = createFilterCondition(
|
|
229
|
+
fieldResolver,
|
|
230
|
+
filter.field,
|
|
231
|
+
filter.operator as FilterOperatorType,
|
|
232
|
+
filter.value
|
|
233
|
+
)
|
|
234
|
+
if (condition) {
|
|
235
|
+
filterConditions.push(condition)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Combine baseWhere and filter conditions with AND
|
|
241
|
+
let finalWhere: SQL | undefined = undefined
|
|
242
|
+
if (baseWhere && filterConditions.length > 0) {
|
|
243
|
+
finalWhere = and(baseWhere, ...filterConditions)!
|
|
244
|
+
} else if (baseWhere) {
|
|
245
|
+
finalWhere = baseWhere
|
|
246
|
+
} else if (filterConditions.length > 0) {
|
|
247
|
+
finalWhere = filterConditions.length === 1 ? filterConditions[0] : and(...filterConditions)!
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 2. Build orderBy
|
|
251
|
+
let orderBy: SQL | undefined = undefined
|
|
252
|
+
if (queryInput.sortField) {
|
|
253
|
+
const column = fieldResolver(queryInput.sortField)
|
|
254
|
+
if (column) {
|
|
255
|
+
const direction = queryInput.sortDirection || 'asc'
|
|
256
|
+
orderBy = direction === 'desc' ? desc(column) : asc(column)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3. Extract limit and offset
|
|
261
|
+
const limit = queryInput.take
|
|
262
|
+
const offset = queryInput.skip
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
where: finalWhere,
|
|
266
|
+
orderBy,
|
|
267
|
+
limit,
|
|
268
|
+
offset
|
|
269
|
+
}
|
|
270
|
+
}
|