@nuxt-customer-portal/core 0.3.3 → 0.4.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 (45) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -0
  3. package/app/components/PortalTimezoneSelect.vue +28 -0
  4. package/app/composables/useClientConfiguration.ts +8 -0
  5. package/app/composables/usePaginatedResource.ts +2 -1
  6. package/app/composables/usePortalSession.ts +2 -0
  7. package/app/composables/useUserStore.ts +5 -0
  8. package/app/utils/auth-client.ts +4 -1
  9. package/i18n/locales/en.json +22 -0
  10. package/i18n/locales/nl.json +22 -0
  11. package/migrations/0006_timezone_preferences.sql +22 -0
  12. package/migrations/0007_user_names.sql +2 -0
  13. package/migrations/0008_better_auth_api_keys.sql +27 -0
  14. package/nuxt.config.ts +16 -5
  15. package/package.json +8 -3
  16. package/portal.manifest.mjs +1 -1
  17. package/server/api/auth/permissions.get.ts +4 -1
  18. package/server/api/timezones.get.ts +9 -0
  19. package/server/db/schema/auth-schema.ts +46 -1
  20. package/server/middleware/auth.ts +10 -0
  21. package/server/utils/auth.ts +150 -12
  22. package/server/utils/business-hooks.ts +34 -0
  23. package/server/utils/client-account-policy.ts +15 -0
  24. package/server/utils/client-configuration.ts +9 -0
  25. package/server/utils/email-texts.ts +27 -0
  26. package/server/utils/email.ts +25 -2
  27. package/server/utils/invitation-management.ts +4 -0
  28. package/server/utils/openapi-contracts.ts +8 -0
  29. package/server/utils/permissions.ts +4 -0
  30. package/server/utils/portal-email.ts +12 -4
  31. package/server/utils/public-api-route.ts +14 -0
  32. package/server/utils/public-invitation-route.ts +3 -0
  33. package/server/utils/timezone-validation.ts +4 -0
  34. package/server/utils/timezones.ts +28 -0
  35. package/shared/appointment-time.ts +43 -0
  36. package/shared/core-feature.ts +82 -29
  37. package/shared/countries.ts +18 -0
  38. package/shared/email-recipient.ts +7 -0
  39. package/shared/languages.ts +7 -0
  40. package/shared/permissions.ts +25 -0
  41. package/shared/portal-session.ts +2 -0
  42. package/shared/social-auth.ts +5 -0
  43. package/shared/timezone.ts +21 -0
  44. package/shared/types/feature.ts +11 -0
  45. package/shared/types/sections/database.ts +3 -0
@@ -1,3 +1,5 @@
1
+ import { z } from 'zod'
2
+ import { isPersonalClient, assertClientInvitationAcceptance } from './client-account-policy'
1
3
  import { isPortalDemo } from './demo'
2
4
  import { demoMessage, isDemoRequestAllowed } from '../../shared/demo-policy'
3
5
  import { betterAuth } from 'better-auth'
@@ -6,6 +8,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
6
8
  import { sendEmail } from './email'
7
9
  import { getInvitationEmailContent, getOTPEmailContent, getDeleteAccountEmailContent } from './email-texts'
8
10
  import { admin as adminPlugin, customSession, emailOTP, openAPI, organization } from 'better-auth/plugins'
11
+ import { apiKey } from '@better-auth/api-key'
9
12
  import { db } from './db'
10
13
  import { and, eq, gt } from 'drizzle-orm'
11
14
  import {
@@ -15,12 +18,23 @@ import {
15
18
  verification as verificationTable,
16
19
  organization as organizationTable,
17
20
  member as organizationMemberTable,
18
- invitation as organizationInvitationTable
21
+ invitation as organizationInvitationTable,
22
+ apiKey as apiKeyTable
19
23
  } from '../db/schema/auth-schema'
20
24
  import { nanoid } from 'nanoid'
21
- import { ac, user, admin as adminRole } from '../../shared/permissions'
25
+ import {
26
+ ac,
27
+ user,
28
+ admin as adminRole,
29
+ organizationAc,
30
+ organizationOwner,
31
+ organizationAdmin,
32
+ organizationMember
33
+ } from '../../shared/permissions'
22
34
  import { canViewOrganizationDirectory } from '../../shared/feature-registry'
23
35
  import { isSystemAdminEmail, parseSystemAdminEmails } from './admin-email-allowlist'
36
+ import { runUserIdentityChangedHooks } from './business-hooks'
37
+ import { socialAuthProviderEnabled } from '../../shared/social-auth'
24
38
 
25
39
  /**
26
40
  * Generate an ID in the same format as better-auth uses (nanoid)
@@ -30,13 +44,20 @@ export function generateId(): string {
30
44
  return nanoid()
31
45
  }
32
46
 
33
- const envFlag = (value: string | undefined, fallback = true) => (value === undefined ? fallback : value === 'true')
34
47
  const portalAuthConfig = useRuntimeConfig().portalAuth
35
48
  const registrationMode = ['open', 'invitation-only', 'disabled'].includes(process.env.PORTAL_REGISTRATION_MODE || '')
36
49
  ? process.env.PORTAL_REGISTRATION_MODE
37
50
  : portalAuthConfig.registrationMode
38
- const githubEnabled = envFlag(process.env.PORTAL_GITHUB_ENABLED, portalAuthConfig.githubEnabled)
39
- const googleEnabled = envFlag(process.env.PORTAL_GOOGLE_ENABLED, portalAuthConfig.googleEnabled)
51
+ const githubEnabled = socialAuthProviderEnabled(
52
+ process.env.PORTAL_GITHUB_ENABLED,
53
+ process.env.GITHUB_CLIENT_ID,
54
+ process.env.GITHUB_CLIENT_SECRET
55
+ )
56
+ const googleEnabled = socialAuthProviderEnabled(
57
+ process.env.PORTAL_GOOGLE_ENABLED,
58
+ process.env.GOOGLE_CLIENT_ID,
59
+ process.env.GOOGLE_CLIENT_SECRET
60
+ )
40
61
  const systemAdminEmails = isPortalDemo() ? new Set<string>() : parseSystemAdminEmails(process.env.ADMIN_EMAILS)
41
62
 
42
63
  export const auth = betterAuth({
@@ -50,7 +71,8 @@ export const auth = betterAuth({
50
71
  member: organizationMemberTable,
51
72
  verification: verificationTable,
52
73
  organization: organizationTable,
53
- invitation: organizationInvitationTable
74
+ invitation: organizationInvitationTable,
75
+ apikey: apiKeyTable
54
76
  },
55
77
  usePlural: false
56
78
  // Tables are singular (e.g., "user"), so no need for usePlural
@@ -66,6 +88,95 @@ export const auth = betterAuth({
66
88
  message: demoMessage(ctx.headers?.get('accept-language') || '')
67
89
  })
68
90
  }
91
+ if (
92
+ ['/api-key/create', '/api-key/list', '/api-key/get', '/api-key/update', '/api-key/delete'].includes(ctx.path)
93
+ ) {
94
+ const body = (ctx.body ?? {}) as Record<string, unknown>
95
+ const query = (ctx.query ?? {}) as Record<string, unknown>
96
+ const current = await getSessionFromCtx(ctx)
97
+ const actorId = (body.userId as string | undefined) ?? current?.user.id
98
+ if (actorId) {
99
+ const [actor] = await db
100
+ .select({ role: userTable.role })
101
+ .from(userTable)
102
+ .where(eq(userTable.id, actorId))
103
+ .limit(1)
104
+ if (actor?.role !== 'admin') {
105
+ throw new APIError('FORBIDDEN', { message: 'System administrator access is required to manage API keys' })
106
+ }
107
+ }
108
+ let organizationId = (body.organizationId ?? query.organizationId) as string | undefined
109
+ const keyId = (body.keyId ?? query.id) as string | undefined
110
+ if (!organizationId && !keyId) {
111
+ organizationId = current?.session.activeOrganizationId ?? undefined
112
+ }
113
+ if (!organizationId && keyId) {
114
+ const [key] = await db
115
+ .select({ referenceId: apiKeyTable.referenceId })
116
+ .from(apiKeyTable)
117
+ .where(eq(apiKeyTable.id, keyId))
118
+ .limit(1)
119
+ organizationId = key?.referenceId
120
+ }
121
+ if (organizationId) {
122
+ const [selected] = await db
123
+ .select({ organizationType: organizationTable.organizationType })
124
+ .from(organizationTable)
125
+ .where(eq(organizationTable.id, organizationId))
126
+ .limit(1)
127
+ if (selected?.organizationType !== 'PROVIDER') {
128
+ throw new APIError('FORBIDDEN', {
129
+ message: 'API keys are only available to provider organizations'
130
+ })
131
+ }
132
+ }
133
+ }
134
+ if (ctx.path.startsWith('/organization/') && ctx.body) {
135
+ const current = await getSessionFromCtx(ctx)
136
+ const body = ctx.body as Record<string, unknown>
137
+ let target = body.organizationId as string | undefined
138
+ if (typeof body.invitationId === 'string') {
139
+ const [invite] = await db
140
+ .select()
141
+ .from(organizationInvitationTable)
142
+ .where(eq(organizationInvitationTable.id, body.invitationId))
143
+ .limit(1)
144
+ target = invite?.organizationId
145
+ }
146
+ const memberId = body.memberId ?? body.memberIdOrEmail
147
+ if (typeof memberId === 'string') {
148
+ const [selected] = await db
149
+ .select()
150
+ .from(organizationMemberTable)
151
+ .where(eq(organizationMemberTable.id, memberId))
152
+ .limit(1)
153
+ target = selected?.organizationId ?? target
154
+ }
155
+ target ||= current?.session.activeOrganizationId ?? undefined
156
+ if (target && (await isPersonalClient(target))) {
157
+ if (ctx.path === '/organization/accept-invitation') {
158
+ if (!current?.user.emailVerified) {
159
+ throw new APIError('FORBIDDEN', { message: 'Verify your email first' })
160
+ }
161
+ try {
162
+ await assertClientInvitationAcceptance(target, current.user.id)
163
+ } catch {
164
+ throw new APIError('CONFLICT', { message: 'Personal account already exists. Contact your coach.' })
165
+ }
166
+ } else if (
167
+ ![
168
+ '/organization/set-active',
169
+ '/organization/reject-invitation',
170
+ '/organization/check-slug',
171
+ '/organization/has-permission'
172
+ ].includes(ctx.path)
173
+ ) {
174
+ throw new APIError('FORBIDDEN', {
175
+ message: 'Personal account membership and organization settings cannot be changed through this route'
176
+ })
177
+ }
178
+ }
179
+ }
69
180
  if (ctx.path !== '/organization/list-members' && ctx.path !== '/organization/list-invitations') {
70
181
  return
71
182
  }
@@ -101,6 +212,11 @@ export const auth = betterAuth({
101
212
  requireEmailVerification: true
102
213
  },
103
214
  user: {
215
+ additionalFields: {
216
+ timezone: { type: 'string', required: false, input: false },
217
+ firstName: { type: 'string', required: false, validator: { input: z.string().trim().min(1).max(80) } },
218
+ lastName: { type: 'string', required: false, validator: { input: z.string().trim().min(1).max(80) } }
219
+ },
104
220
  deleteUser: {
105
221
  enabled: true,
106
222
  sendDeleteAccountVerification: async ({ user, url, token: _token }, _request) => {
@@ -130,19 +246,19 @@ export const auth = betterAuth({
130
246
  }
131
247
  },
132
248
  socialProviders: {
133
- ...(githubEnabled && process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
249
+ ...(githubEnabled
134
250
  ? {
135
251
  github: {
136
- clientId: process.env.GITHUB_CLIENT_ID,
137
- clientSecret: process.env.GITHUB_CLIENT_SECRET
252
+ clientId: process.env.GITHUB_CLIENT_ID!,
253
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!
138
254
  }
139
255
  }
140
256
  : {}),
141
- ...(googleEnabled && process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
257
+ ...(googleEnabled
142
258
  ? {
143
259
  google: {
144
- clientId: process.env.GOOGLE_CLIENT_ID,
145
- clientSecret: process.env.GOOGLE_CLIENT_SECRET
260
+ clientId: process.env.GOOGLE_CLIENT_ID!,
261
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!
146
262
  }
147
263
  }
148
264
  : {})
@@ -209,6 +325,14 @@ export const auth = betterAuth({
209
325
  createdAt: new Date()
210
326
  })
211
327
  }
328
+ },
329
+ update: {
330
+ after: async (updatedUser) => {
331
+ await runUserIdentityChangedHooks(db, updatedUser.id, {
332
+ firstName: typeof updatedUser.firstName === 'string' ? updatedUser.firstName : null,
333
+ lastName: typeof updatedUser.lastName === 'string' ? updatedUser.lastName : null
334
+ })
335
+ }
212
336
  }
213
337
  },
214
338
  session: {
@@ -238,6 +362,12 @@ export const auth = betterAuth({
238
362
  }),
239
363
  organization({
240
364
  allowUserToCreateOrganization: false,
365
+ ac: organizationAc,
366
+ roles: {
367
+ owner: organizationOwner,
368
+ admin: organizationAdmin,
369
+ member: organizationMember
370
+ },
241
371
  schema: {
242
372
  organization: {
243
373
  additionalFields: {
@@ -290,6 +420,14 @@ export const auth = betterAuth({
290
420
  }
291
421
  }
292
422
  }),
423
+ apiKey({
424
+ references: 'organization',
425
+ defaultPrefix: 'portal_',
426
+ requireName: true,
427
+ startingCharactersConfig: { shouldStore: true, charactersLength: 12 },
428
+ keyExpiration: { minExpiresIn: 1 / 24, maxExpiresIn: 365 },
429
+ rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 120 }
430
+ }),
293
431
  emailOTP({
294
432
  overrideDefaultEmailVerification: true,
295
433
  async sendVerificationOTP({ email, otp, type }) {
@@ -26,3 +26,37 @@ export const assertTimeEntriesReopenable = async (organizationId: string, entryI
26
26
  await guard(organizationId, entryIds)
27
27
  }
28
28
  }
29
+
30
+ type UserDisplayNameChangedHook = (transaction: unknown, userId: string, name: string) => Promise<void>
31
+ const userDisplayNameChangedHooks: UserDisplayNameChangedHook[] = []
32
+ export const registerUserDisplayNameChangedHook = (hook: UserDisplayNameChangedHook) => {
33
+ if (!userDisplayNameChangedHooks.includes(hook)) {
34
+ userDisplayNameChangedHooks.push(hook)
35
+ }
36
+ }
37
+ export const runUserDisplayNameChangedHooks = async (transaction: unknown, userId: string, name: string) => {
38
+ for (const hook of userDisplayNameChangedHooks) {
39
+ await hook(transaction, userId, name)
40
+ }
41
+ }
42
+
43
+ type UserIdentityChangedHook = (
44
+ transaction: unknown,
45
+ userId: string,
46
+ identity: { firstName: string | null; lastName: string | null }
47
+ ) => Promise<void>
48
+ const userIdentityChangedHooks: UserIdentityChangedHook[] = []
49
+ export const registerUserIdentityChangedHook = (hook: UserIdentityChangedHook) => {
50
+ if (!userIdentityChangedHooks.includes(hook)) {
51
+ userIdentityChangedHooks.push(hook)
52
+ }
53
+ }
54
+ export const runUserIdentityChangedHooks = async (
55
+ transaction: unknown,
56
+ userId: string,
57
+ identity: { firstName: string | null; lastName: string | null }
58
+ ) => {
59
+ for (const hook of userIdentityChangedHooks) {
60
+ await hook(transaction, userId, identity)
61
+ }
62
+ }
@@ -0,0 +1,15 @@
1
+ /** Optional client package policies; core remains usable without Clients. */
2
+ type AccountPolicy = {
3
+ isPersonal: (organizationId: string) => Promise<boolean>
4
+ assertAcceptance: (organizationId: string, userId: string) => Promise<void>
5
+ getIdentity: (organizationId: string) => Promise<{ name: string; firstName?: string; lastName?: string } | null>
6
+ }
7
+ let policy: AccountPolicy | undefined
8
+ export const registerClientAccountPolicy = (value: AccountPolicy) => {
9
+ policy = value
10
+ }
11
+ export const isPersonalClient = async (id: string) => (policy ? policy.isPersonal(id) : false)
12
+ export const assertClientInvitationAcceptance = async (id: string, userId: string) => {
13
+ await policy?.assertAcceptance(id, userId)
14
+ }
15
+ export const getClientAccountIdentity = async (id: string) => (policy ? policy.getIdentity(id) : null)
@@ -0,0 +1,9 @@
1
+ export interface ClientConfigurationOverride {
2
+ allowedTypes: ('organization' | 'person')[]
3
+ personalSelfRegistration: boolean
4
+ }
5
+ let resolver: (() => Promise<ClientConfigurationOverride>) | undefined
6
+ export const registerClientConfigurationResolver = (value: typeof resolver) => {
7
+ resolver = value
8
+ }
9
+ export const resolveClientConfigurationOverride = () => resolver?.()
@@ -38,6 +38,33 @@ export function getInvitationEmailContent(params: InvitationEmailParams) {
38
38
  }
39
39
  }
40
40
 
41
+ export interface PersonalAccountInvitationEmailParams {
42
+ invitationLink: string
43
+ recipientName?: string | null
44
+ }
45
+
46
+ export function getPersonalAccountInvitationEmailContent(params: PersonalAccountInvitationEmailParams) {
47
+ return {
48
+ messageId: 'personal-account-invitation',
49
+ values: {
50
+ ...(params.recipientName?.trim() ? { recipient_name: params.recipientName.trim() } : {}),
51
+ action_url: params.invitationLink
52
+ },
53
+ subject: 'Set up your personal portal account',
54
+ params: {
55
+ greeting: 'Hello,',
56
+ body_text: `A personal portal profile has been created for you. Set up your account to access your purchases, appointments, invoices, and shared files.
57
+ <p style="text-align: center;">
58
+ <a href="${params.invitationLink}" class="button">Set up your account</a>
59
+ </p>
60
+ `,
61
+ action_url: params.invitationLink,
62
+ action_text: 'Set up your account',
63
+ footer_text: 'This account setup link expires soon. If you did not expect this message, please ignore it.'
64
+ }
65
+ }
66
+ }
67
+
41
68
  /**
42
69
  * Centralized OTP email content
43
70
  * Used for email verification, sign-in, and password reset OTP codes
@@ -1,4 +1,6 @@
1
1
  import { coreFeature } from '@nuxt-customer-portal/core/shared/core-feature'
2
+ import { emailRecipientName } from '@nuxt-customer-portal/core/shared/email-recipient'
3
+ import { pool } from './db'
2
4
  import { sendPortalEmail } from './portal-email'
3
5
 
4
6
  // Define the structure for the parameters used in the template
@@ -18,20 +20,41 @@ interface SendEmailArgs {
18
20
  messageId?: string
19
21
  values?: Record<string, string>
20
22
  locale?: string
23
+ idempotencyKey?: string
21
24
  }
22
25
 
23
26
  // Updated sendEmail function using the template
24
- export const sendEmail = async ({ to, subject, params, messageId, values = {}, locale }: SendEmailArgs) => {
27
+ export const sendEmail = async ({
28
+ to,
29
+ subject,
30
+ params,
31
+ messageId,
32
+ values = {},
33
+ locale,
34
+ idempotencyKey
35
+ }: SendEmailArgs) => {
25
36
  const definition = coreFeature.emails?.find((item) => item.id === messageId)
26
37
  if (!definition) {
27
38
  throw new Error(`Unknown core email definition: ${messageId}`)
28
39
  }
40
+ const recipient = await pool.query<{ first_name: string | null; name: string | null }>(
41
+ 'SELECT first_name,name FROM public."user" WHERE lower(email)=lower($1) LIMIT 1',
42
+ [to]
43
+ )
29
44
  return sendPortalEmail({
30
45
  moduleId: coreFeature.id,
31
46
  definition,
32
47
  locale,
33
- values,
48
+ values: {
49
+ recipient_name: emailRecipientName({
50
+ firstName: recipient.rows[0]?.first_name,
51
+ displayName: recipient.rows[0]?.name,
52
+ email: to
53
+ }),
54
+ ...values
55
+ },
34
56
  to,
57
+ idempotencyKey,
35
58
  text: messageId ? undefined : { subject, body: params.body_text, footer: params.footer_text }
36
59
  })
37
60
  }
@@ -1,3 +1,4 @@
1
+ import { isPersonalClient } from './client-account-policy'
1
2
  import { and, eq, gt } from 'drizzle-orm'
2
3
  import { createError } from 'h3'
3
4
  import { invitationChangeSchema } from '../../shared/invitation-validation'
@@ -10,6 +11,9 @@ export const changePendingInvitation = async (organizationId: string, invitation
10
11
  throw createError({ statusCode: 400, message: 'Invalid invitation change' })
11
12
  }
12
13
  const change = parsed.data
14
+ if ('role' in change && (await isPersonalClient(organizationId))) {
15
+ throw createError({ statusCode: 403, message: 'Personal invitation roles cannot be changed' })
16
+ }
13
17
  const [updated] = await db
14
18
  .update(invitation)
15
19
  .set(change)
@@ -6,6 +6,7 @@ type OpenApiOperation = Record<string, unknown>
6
6
 
7
7
  export interface PortalOpenApiContracts {
8
8
  owner: string
9
+ securitySchemes?: Record<string, Record<string, unknown>>
9
10
  query?: Record<string, z.ZodType>
10
11
  body?: Record<string, z.ZodType>
11
12
  requestBody?: Record<string, Record<string, unknown>>
@@ -120,6 +121,13 @@ const addContract = (operation: OpenApiOperation): void => {
120
121
  }
121
122
 
122
123
  export const enrichOpenApiContracts = <T extends OpenApiDocument>(document: T): T => {
124
+ const components = (document.components ?? {}) as Record<string, unknown>
125
+ components.securitySchemes = Object.assign(
126
+ {},
127
+ components.securitySchemes,
128
+ ...(contractRegistry[contractRegistryKey] ?? []).map((item) => item.securitySchemes ?? {})
129
+ )
130
+ Object.assign(document, { components })
123
131
  for (const pathItem of Object.values(document.paths ?? {})) {
124
132
  for (const operation of Object.values(pathItem)) {
125
133
  addContract(operation)
@@ -1,3 +1,4 @@
1
+ import { isPersonalClient } from './client-account-policy'
1
2
  import { db } from './db'
2
3
  import { member as memberTable, organization as organizationTable } from '../db/schema/auth-schema'
3
4
  import { eq, and } from 'drizzle-orm'
@@ -113,6 +114,9 @@ export const hasPermission = async (
113
114
  subject: string,
114
115
  action: string
115
116
  ): Promise<boolean> => {
117
+ if (organizationId && ['member', 'invitation'].includes(subject) && (await isPersonalClient(organizationId))) {
118
+ return false
119
+ }
116
120
  const { permissions } = await getUserPermissions(userId, systemRole, organizationId)
117
121
  const subjectPermissions = permissions[subject] || []
118
122
  return Array.isArray(subjectPermissions) && subjectPermissions.includes(action)
@@ -3,6 +3,7 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:
3
3
  import { eq } from 'drizzle-orm'
4
4
  import { createError } from 'h3'
5
5
  import { Resend } from 'resend'
6
+ import { marked } from 'marked'
6
7
  import type {
7
8
  PortalEmailDefinition,
8
9
  PortalEmailLocale,
@@ -47,6 +48,9 @@ const TEMPLATE_PLACEHOLDERS = new Set([
47
48
  const placeholderPattern = /{{\s*([a-z0-9_]+)\s*}}/gi
48
49
  const senderPattern = /^\s*([^<>]+?)\s*<\s*([^<>]+)\s*>\s*$/
49
50
 
51
+ export const renderPortalEmailMarkdown = (value: string) =>
52
+ marked.parse(value, { async: false, breaks: true, gfm: true }) as string
53
+
50
54
  const parseSender = (value = '') => {
51
55
  const match = value.match(senderPattern)
52
56
  return match ? { fromName: match[1]!.trim(), fromEmail: match[2]!.trim() } : { fromName: '', fromEmail: value.trim() }
@@ -341,8 +345,10 @@ export const renderPortalEmail = async (input: {
341
345
  const messageValues = { ...input.values, brand_name: brandName }
342
346
  const allowed = new Set([...input.definition.placeholders.map((item) => item.key), 'brand_name'])
343
347
  const subject = replacePlaceholders(text.subject, messageValues, allowed)
344
- const body = replacePlaceholders(text.body, messageValues, allowed)
345
- const footer = replacePlaceholders(text.footer ?? '', messageValues, allowed)
348
+ const bodySource = replacePlaceholders(text.body, messageValues, allowed)
349
+ const footerSource = replacePlaceholders(text.footer ?? '', messageValues, allowed)
350
+ const body = renderPortalEmailMarkdown(bodySource)
351
+ const footer = renderPortalEmailMarkdown(footerSource)
346
352
  const htmlTemplate = input.htmlTemplate ?? settings.htmlTemplate
347
353
  validatePortalEmailTemplate(htmlTemplate)
348
354
  const inlineAttachments: PortalEmailAttachment[] = []
@@ -392,6 +398,7 @@ export const sendPortalEmail = async (input: {
392
398
  fromName?: string
393
399
  attachments?: PortalEmailAttachment[]
394
400
  idempotencyKey?: string
401
+ subjectPrefix?: string
395
402
  }) => {
396
403
  if (isPortalDemo()) {
397
404
  rejectDemoAction()
@@ -400,6 +407,7 @@ export const sendPortalEmail = async (input: {
400
407
  renderPortalEmail({ ...input, inlineBrandAssets: true }),
401
408
  providerConfiguration()
402
409
  ])
410
+ const subject = `${input.subjectPrefix ?? ''}${rendered.subject}`
403
411
  const fromEmail = input.fromEmail || provider.fromEmail
404
412
  const fromName = input.fromName || provider.fromName
405
413
  const from = fromName ? `${fromName.replace(/[<>\r\n]/g, '')} <${fromEmail}>` : fromEmail
@@ -408,7 +416,7 @@ export const sendPortalEmail = async (input: {
408
416
  from,
409
417
  to: [input.to],
410
418
  cc: input.cc?.length ? input.cc : undefined,
411
- subject: rendered.subject,
419
+ subject,
412
420
  html: rendered.html,
413
421
  text: rendered.text,
414
422
  attachments: [...rendered.inlineAttachments, ...(input.attachments ?? [])]
@@ -418,7 +426,7 @@ export const sendPortalEmail = async (input: {
418
426
  if (error) {
419
427
  throw new Error(error.message)
420
428
  }
421
- return { ...data, rendered }
429
+ return { ...data, rendered: { ...rendered, subject } }
422
430
  }
423
431
 
424
432
  export const retrievePortalEmail = async (providerMessageId: string) => {
@@ -0,0 +1,14 @@
1
+ export function isPublicStoreRoute(method: string, pathname: string) {
2
+ return (
3
+ (method === 'GET' &&
4
+ (/^\/api\/store\/v1\/products(?:\/[^/]+)?$/.test(pathname) ||
5
+ /^\/api\/store\/(product|media)\/[^/]+$/.test(pathname) ||
6
+ /^\/api\/store\/planning\/[^/]+\/availability$/.test(pathname) ||
7
+ pathname === '/api/store/planning/hold' ||
8
+ /^\/api\/store\/sandbox-checkout\/[^/]+$/.test(pathname))) ||
9
+ (method === 'POST' &&
10
+ (['/api/store/checkout', '/api/store/webhooks/stripe', '/api/store/planning/holds'].includes(pathname) ||
11
+ /^\/api\/store\/sandbox-checkout\/[^/]+$/.test(pathname))) ||
12
+ (method === 'DELETE' && pathname === '/api/store/planning/hold')
13
+ )
14
+ }
@@ -0,0 +1,3 @@
1
+ export const isPublicInvitationRoute = (method: string, pathname: string) =>
2
+ (method === 'GET' && pathname === '/api/organizations/get-invitation') ||
3
+ (method === 'POST' && pathname === '/api/organizations/invitation-signup')
@@ -0,0 +1,4 @@
1
+ import { z } from 'zod'
2
+ import { isValidTimezone } from '../../shared/timezone'
3
+
4
+ export const timezoneSchema = z.string().trim().max(100).refine(isValidTimezone, 'A valid IANA timezone is required')
@@ -0,0 +1,28 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { db } from './db'
3
+ import { organization, organizationSettings, user } from '../db/schema/auth-schema'
4
+ import { DEFAULT_TIMEZONE, resolveTimezones } from '../../shared/timezone'
5
+
6
+ export const getProviderTimezone = async () => {
7
+ const [provider] = await db
8
+ .select({ timezone: organizationSettings.timezone })
9
+ .from(organization)
10
+ .leftJoin(organizationSettings, eq(organization.id, organizationSettings.organizationId))
11
+ .where(eq(organization.organizationType, 'PROVIDER'))
12
+ .limit(1)
13
+ return provider?.timezone || DEFAULT_TIMEZONE
14
+ }
15
+ type ClientTimezoneResolver = (id: string) => Promise<string | null>
16
+ let clientResolver: ClientTimezoneResolver = async () => null
17
+ export const registerClientTimezoneResolver = (resolver: ClientTimezoneResolver) => {
18
+ clientResolver = resolver
19
+ }
20
+ /** Caller must authorize the user and active organization before exposing this projection. */
21
+ export const getResolvedTimezones = async (userId: string, clientOrganizationId?: string | null) => {
22
+ const [providerTimezone, clientTimezone, users] = await Promise.all([
23
+ getProviderTimezone(),
24
+ clientOrganizationId ? clientResolver(clientOrganizationId) : null,
25
+ db.select({ timezone: user.timezone }).from(user).where(eq(user.id, userId)).limit(1)
26
+ ])
27
+ return resolveTimezones({ providerTimezone, clientTimezone, userTimezone: users[0]?.timezone ?? null })
28
+ }
@@ -0,0 +1,43 @@
1
+ export interface AppointmentTimeOptions {
2
+ locale?: string | string[]
3
+ timeZone: string
4
+ midnight: string
5
+ dateStyle?: Intl.DateTimeFormatOptions['dateStyle']
6
+ includeDate?: boolean
7
+ }
8
+
9
+ export function formatAppointmentTime(value: string | Date, options: AppointmentTimeOptions): string {
10
+ const date = new Date(value)
11
+ const parts = new Intl.DateTimeFormat('en', {
12
+ hour: '2-digit',
13
+ minute: '2-digit',
14
+ hourCycle: 'h23',
15
+ timeZone: options.timeZone
16
+ }).formatToParts(date)
17
+ if (
18
+ parts.find((part) => part.type === 'hour')?.value === '00' &&
19
+ parts.find((part) => part.type === 'minute')?.value === '00'
20
+ ) {
21
+ return options.midnight
22
+ }
23
+ return new Intl.DateTimeFormat(options.locale, { timeStyle: 'short', timeZone: options.timeZone }).format(date)
24
+ }
25
+
26
+ export function formatAppointmentRange(
27
+ start: string | Date,
28
+ end: string | Date,
29
+ options: AppointmentTimeOptions
30
+ ): string {
31
+ if (options.includeDate === false) {
32
+ return `${formatAppointmentTime(start, options)} – ${formatAppointmentTime(end, options)}`
33
+ }
34
+ const formatter = new Intl.DateTimeFormat(options.locale, {
35
+ dateStyle: options.dateStyle ?? 'long',
36
+ timeStyle: 'short',
37
+ timeZone: options.timeZone
38
+ })
39
+ const endTime = formatAppointmentTime(end, options)
40
+ return endTime === options.midnight
41
+ ? `${formatter.format(new Date(start))} – ${options.midnight}`
42
+ : formatter.formatRange(new Date(start), new Date(end))
43
+ }