@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/app/composables/useAutoPagination.ts +30 -0
  5. package/app/composables/useOrganization.ts +117 -0
  6. package/app/composables/usePaginatedResource.ts +167 -0
  7. package/app/composables/usePortalFeatures.ts +74 -0
  8. package/app/composables/usePortalSession.ts +20 -0
  9. package/app/composables/useUserStore.ts +460 -0
  10. package/app/middleware/auth.global.ts +32 -0
  11. package/app/plugins/auth.ts +28 -0
  12. package/app/plugins/portal-core-feature.ts +3 -0
  13. package/app/types/index.d.ts +75 -0
  14. package/app/utils/auth-client.ts +31 -0
  15. package/app/utils/permission-utils.ts +21 -0
  16. package/app/utils/random.ts +9 -0
  17. package/i18n/locales/en.json +81 -0
  18. package/i18n/locales/nl.json +81 -0
  19. package/migrations/0000_baseline.sql +119 -0
  20. package/migrations/0001_organization_types.sql +12 -0
  21. package/migrations/0002_invitation_created_at.sql +1 -0
  22. package/migrations/0003_system_admin_role.sql +5 -0
  23. package/migrations/0004_provider_organization_type.sql +16 -0
  24. package/migrations/0005_portal_email_settings.sql +15 -0
  25. package/migrations/meta/0000_snapshot.json +880 -0
  26. package/migrations/meta/_journal.json +13 -0
  27. package/nuxt.config.ts +55 -0
  28. package/package.json +66 -0
  29. package/portal.manifest.mjs +8 -0
  30. package/server/api/auth/[...].ts +18 -0
  31. package/server/api/auth/permissions.get.ts +64 -0
  32. package/server/api/notifications.ts +294 -0
  33. package/server/db/core-schema.ts +1 -0
  34. package/server/db/schema/auth-schema.ts +198 -0
  35. package/server/db/schema/index.ts +1 -0
  36. package/server/middleware/api-docs-auth.ts +26 -0
  37. package/server/middleware/auth.ts +61 -0
  38. package/server/portal.ts +212 -0
  39. package/server/routes/api-docs/openapi.json.get.ts +22 -0
  40. package/server/utils/admin-email-allowlist.ts +12 -0
  41. package/server/utils/auth.ts +326 -0
  42. package/server/utils/business-hooks.ts +28 -0
  43. package/server/utils/db.ts +18 -0
  44. package/server/utils/email-template.html +105 -0
  45. package/server/utils/email-texts.ts +110 -0
  46. package/server/utils/email.ts +37 -0
  47. package/server/utils/invitation-management.ts +29 -0
  48. package/server/utils/openapi-auth.ts +67 -0
  49. package/server/utils/openapi-contracts.ts +129 -0
  50. package/server/utils/openapi-order.ts +136 -0
  51. package/server/utils/permissions.ts +179 -0
  52. package/server/utils/portal-email.ts +424 -0
  53. package/server/utils/query-builder.ts +270 -0
  54. package/server/utils/route-meta.ts +28 -0
  55. package/shared/core-feature.ts +152 -0
  56. package/shared/feature-registry.ts +62 -0
  57. package/shared/invitation-validation.ts +8 -0
  58. package/shared/organization-avatar.ts +11 -0
  59. package/shared/permissions.ts +26 -0
  60. package/shared/portal-session.ts +17 -0
  61. package/shared/types/feature.ts +130 -0
  62. package/shared/types/index.ts +40 -0
  63. package/shared/types/sections/api.ts +134 -0
  64. package/shared/types/sections/auth.ts +71 -0
  65. package/shared/types/sections/database.ts +86 -0
  66. 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
+ }