@open-mercato/core 0.7.1-develop.7149.1.7efa6e1612 → 0.7.1-develop.7151.1.00d0391847

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 (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +77 -1
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/helpers/integration/crmFixtures.js +3 -0
  5. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  6. package/dist/modules/auth/api/reset.js +7 -1
  7. package/dist/modules/auth/api/reset.js.map +2 -2
  8. package/dist/modules/auth/api/users/resend-invite/route.js +7 -1
  9. package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
  10. package/dist/modules/auth/commands/users.js +7 -1
  11. package/dist/modules/auth/commands/users.js.map +2 -2
  12. package/dist/modules/communication_channels/api/post/test-seed/route.js +101 -26
  13. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +3 -3
  14. package/dist/modules/communication_channels/di.js +8 -0
  15. package/dist/modules/communication_channels/di.js.map +2 -2
  16. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js +57 -0
  17. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js.map +7 -0
  18. package/dist/modules/communication_channels/lib/system-email-provider-config.js +32 -0
  19. package/dist/modules/communication_channels/lib/system-email-provider-config.js.map +7 -0
  20. package/dist/modules/communication_channels/lib/system-email.js +190 -0
  21. package/dist/modules/communication_channels/lib/system-email.js.map +7 -0
  22. package/dist/modules/communication_channels/lib/test-seed.js +130 -1
  23. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  24. package/dist/modules/communication_channels/setup.js +56 -1
  25. package/dist/modules/communication_channels/setup.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/admin/users-invite.js +1 -0
  27. package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/portal/users-invite.js +1 -0
  29. package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/signup.js +8 -4
  31. package/dist/modules/customer_accounts/api/signup.js.map +2 -2
  32. package/dist/modules/customer_accounts/lib/invitationEmail.js +3 -1
  33. package/dist/modules/customer_accounts/lib/invitationEmail.js.map +2 -2
  34. package/dist/modules/customers/api/interactions/route.js +3 -0
  35. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  36. package/dist/modules/customers/commands/tags.js +8 -2
  37. package/dist/modules/customers/commands/tags.js.map +2 -2
  38. package/dist/modules/messages/lib/email-sender.js +16 -14
  39. package/dist/modules/messages/lib/email-sender.js.map +2 -2
  40. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
  41. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
  42. package/dist/modules/sales/api/quotes/accept/route.js +3 -1
  43. package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
  44. package/dist/modules/sales/api/quotes/send/route.js +13 -4
  45. package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
  46. package/dist/modules/staff/commands/timesheets-entries.js +4 -2
  47. package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
  48. package/dist/modules/staff/commands/timesheets-projects.js +4 -2
  49. package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
  50. package/package.json +7 -7
  51. package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
  52. package/src/helpers/integration/crmFixtures.ts +4 -1
  53. package/src/modules/auth/api/reset.ts +7 -1
  54. package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
  55. package/src/modules/auth/commands/users.ts +7 -1
  56. package/src/modules/auth/i18n/de.json +4 -0
  57. package/src/modules/auth/i18n/en.json +4 -0
  58. package/src/modules/auth/i18n/es.json +4 -0
  59. package/src/modules/auth/i18n/ko.json +4 -0
  60. package/src/modules/auth/i18n/pl.json +4 -0
  61. package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
  62. package/src/modules/communication_channels/di.ts +8 -0
  63. package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
  64. package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
  65. package/src/modules/communication_channels/lib/system-email.ts +316 -0
  66. package/src/modules/communication_channels/lib/test-seed.ts +198 -0
  67. package/src/modules/communication_channels/setup.ts +91 -1
  68. package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
  69. package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
  70. package/src/modules/customer_accounts/api/signup.ts +6 -2
  71. package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
  72. package/src/modules/customers/api/interactions/route.ts +4 -0
  73. package/src/modules/customers/commands/tags.ts +12 -1
  74. package/src/modules/messages/lib/email-sender.ts +18 -16
  75. package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
  76. package/src/modules/sales/api/quotes/accept/route.ts +2 -0
  77. package/src/modules/sales/api/quotes/send/route.ts +16 -3
  78. package/src/modules/staff/commands/timesheets-entries.ts +2 -0
  79. package/src/modules/staff/commands/timesheets-projects.ts +2 -0
@@ -1,7 +1,11 @@
1
1
  import { createHash } from 'node:crypto'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
5
  import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
6
+ import { CommunicationChannel } from './data/entities'
3
7
  import { COMMUNICATION_CHANNELS_QUEUES } from './lib/queue'
4
- import { createLogger } from '@open-mercato/shared/lib/logger'
8
+ import { isTestChannelSeedingEnabled, TEST_SEED_PROVIDER_KEY } from './lib/test-seed'
5
9
 
6
10
  const logger = createLogger('communication_channels')
7
11
 
@@ -25,6 +29,24 @@ type SchedulerServiceLike = {
25
29
  }) => Promise<void>
26
30
  }
27
31
 
32
+ type CredentialsServiceLike = {
33
+ resolve: (
34
+ integrationId: string,
35
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
36
+ ) => Promise<Record<string, unknown> | null>
37
+ save: (
38
+ integrationId: string,
39
+ credentials: Record<string, unknown>,
40
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
41
+ ) => Promise<void>
42
+ }
43
+
44
+ /**
45
+ * Sender the test-seed channel presents. Shared by the channel row's
46
+ * `externalIdentifier` and its seeded credentials so both describe one address.
47
+ */
48
+ const TEST_SEED_FROM_ADDRESS = 'system@test-seed.local'
49
+
28
50
  /**
29
51
  * Tick interval in seconds. Default 60s per email integration spec
30
52
  * § Hub Deltas → Delta 6 (scheduler mechanism).
@@ -88,6 +110,74 @@ export const setup: ModuleSetupConfig = {
88
110
  },
89
111
 
90
112
  async seedDefaults({ container, organizationId, tenantId }) {
113
+ if (isTestChannelSeedingEnabled() && process.env.SYSTEM_EMAIL_PROVIDER === TEST_SEED_PROVIDER_KEY) {
114
+ const em = (container.resolve('em') as EntityManager).fork()
115
+ const existing = await findOneWithDecryption(
116
+ em,
117
+ CommunicationChannel,
118
+ {
119
+ providerKey: TEST_SEED_PROVIDER_KEY,
120
+ channelType: 'email',
121
+ tenantId,
122
+ organizationId,
123
+ userId: null,
124
+ deletedAt: null,
125
+ },
126
+ undefined,
127
+ { tenantId, organizationId },
128
+ )
129
+ if (!existing) {
130
+ em.persist(em.create(CommunicationChannel, {
131
+ providerKey: TEST_SEED_PROVIDER_KEY,
132
+ channelType: 'email',
133
+ displayName: 'Test Seed System Email',
134
+ externalIdentifier: TEST_SEED_FROM_ADDRESS,
135
+ userId: null,
136
+ isPrimary: false,
137
+ isActive: true,
138
+ status: 'connected',
139
+ tenantId,
140
+ organizationId,
141
+ }))
142
+ await em.flush()
143
+ }
144
+
145
+ /**
146
+ * Seeding the channel row without credentials is not a neutral state — it is a
147
+ * fail-closed one. Once a tenant owns a Hub channel, `resolveTenantCredentials`
148
+ * deliberately refuses to fall back to instance-wide env credentials, so every
149
+ * tenant-scoped send throws `SYSTEM_EMAIL_CREDENTIALS_NOT_CONFIGURED`. Callers that
150
+ * treat a failed send as a failed write (customer invitations roll back and return
151
+ * 502) then fail outright rather than merely losing the email.
152
+ *
153
+ * Seeding credentials alongside the row makes the harness model a tenant that
154
+ * finished configuring its provider, which is also the only one of the three
155
+ * credential cases the integration suite would otherwise never exercise.
156
+ */
157
+ // Best-effort, like the scheduler registration below: credential storage needs the
158
+ // integrations module and a usable encryption key, and neither is guaranteed in every
159
+ // harness. A failure here must not abort tenant initialization for every other module.
160
+ try {
161
+ const credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike
162
+ const testSeedIntegrationId = `channel_${TEST_SEED_PROVIDER_KEY}`
163
+ const credentialScope = { tenantId, organizationId, userId: null }
164
+ const existingCredentials = await credentialsService.resolve(testSeedIntegrationId, credentialScope)
165
+ if (!existingCredentials) {
166
+ await credentialsService.save(
167
+ testSeedIntegrationId,
168
+ { testSeed: true, fromAddress: TEST_SEED_FROM_ADDRESS },
169
+ credentialScope,
170
+ )
171
+ }
172
+ } catch (error) {
173
+ logger.warn('Test-seed email credentials could not be stored; tenant-scoped sends will fail closed', {
174
+ err: error,
175
+ tenantId,
176
+ organizationId,
177
+ })
178
+ }
179
+ }
180
+
91
181
  /**
92
182
  * Register the per-channel polling tick with `@open-mercato/scheduler`.
93
183
  *
@@ -208,6 +208,7 @@ export async function POST(req: Request) {
208
208
  try {
209
209
  await sendCustomerInvitationEmail({
210
210
  container,
211
+ tenantId: auth.tenantId!,
211
212
  organizationId: auth.orgId!,
212
213
  email: invitation.email,
213
214
  rawToken,
@@ -108,6 +108,7 @@ export async function POST(req: Request) {
108
108
  try {
109
109
  await sendCustomerInvitationEmail({
110
110
  container,
111
+ tenantId: auth.tenantId,
111
112
  organizationId: auth.orgId,
112
113
  email: invitation.email,
113
114
  rawToken,
@@ -140,10 +140,12 @@ export async function POST(req: Request) {
140
140
  ),
141
141
  }
142
142
 
143
- void sendEmail({
143
+ await sendEmail({
144
144
  to: existing.email,
145
145
  subject,
146
146
  react: CustomerExistingAccountEmail({ loginUrl, copy }),
147
+ tenantId,
148
+ organizationId,
147
149
  }).catch((error) => {
148
150
  logger.error('Existing-account email failed', { err: error })
149
151
  })
@@ -193,10 +195,12 @@ export async function POST(req: Request) {
193
195
  ),
194
196
  }
195
197
 
196
- void sendEmail({
198
+ await sendEmail({
197
199
  to: user.email,
198
200
  subject,
199
201
  react: CustomerSignupVerificationEmail({ verifyUrl, copy }),
202
+ tenantId,
203
+ organizationId,
200
204
  }).catch((error) => {
201
205
  logger.error('Verification email failed', { err: error })
202
206
  })
@@ -6,6 +6,7 @@ import { urlForCustomerOrg } from '@open-mercato/core/modules/customer_accounts/
6
6
 
7
7
  export type CustomerInvitationEmailInput = {
8
8
  container: AppContainer
9
+ tenantId?: string
9
10
  organizationId: string
10
11
  email: string
11
12
  rawToken: string
@@ -38,5 +39,7 @@ export async function sendCustomerInvitationEmail(input: CustomerInvitationEmail
38
39
  to: input.email,
39
40
  subject,
40
41
  react: CustomerInvitationEmail({ inviteUrl, copy }),
42
+ tenantId: input.tenantId,
43
+ organizationId: input.organizationId,
41
44
  })
42
45
  }
@@ -172,6 +172,7 @@ type InteractionListRow = {
172
172
  priority: number | null
173
173
  author_user_id: string | null
174
174
  owner_user_id: string | null
175
+ external_message_id: string | null
175
176
  appearance_icon: string | null
176
177
  appearance_color: string | null
177
178
  source: string | null
@@ -304,6 +305,7 @@ const INTERACTION_LIST_COLUMNS = [
304
305
  'priority',
305
306
  'author_user_id',
306
307
  'owner_user_id',
308
+ 'external_message_id',
307
309
  'appearance_icon',
308
310
  'appearance_color',
309
311
  'source',
@@ -654,6 +656,7 @@ export async function GET(req: Request) {
654
656
  priority: row.priority ?? null,
655
657
  authorUserId: row.author_user_id ?? null,
656
658
  ownerUserId: row.owner_user_id ?? null,
659
+ externalMessageId: row.external_message_id ?? null,
657
660
  appearanceIcon: row.appearance_icon ?? null,
658
661
  appearanceColor: row.appearance_color ?? null,
659
662
  source: row.source ?? null,
@@ -733,6 +736,7 @@ const interactionListItemSchema = z
733
736
  priority: z.number().nullable(),
734
737
  authorUserId: z.string().uuid().nullable(),
735
738
  ownerUserId: z.string().uuid().nullable(),
739
+ externalMessageId: z.string().uuid().nullable().optional(),
736
740
  appearanceIcon: z.string().nullable().optional(),
737
741
  appearanceColor: z.string().nullable().optional(),
738
742
  source: z.string().nullable().optional(),
@@ -22,11 +22,20 @@ import {
22
22
  } from './shared'
23
23
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
24
24
  import { CrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'
25
- import type { CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
25
+ import type { CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
26
26
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
27
27
  import { emitCustomersEvent } from '../events'
28
28
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
29
29
  import { makeCreateRedo } from '@open-mercato/shared/lib/commands/redo'
30
+ import { E } from '#generated/entities.ids.generated'
31
+
32
+ // The route (`api/tags/route.ts`) declares this same entity type and hands it down to the
33
+ // execute path, but an undo runs through `CommandBus.undo()` where no route declaration is
34
+ // active — so the undo marks carry the indexer explicitly, or a restored tag would stay
35
+ // invisible to every index-backed list until the next full rebuild (#5741).
36
+ const tagCrudIndexer: CrudIndexerConfig<CustomerTag> = {
37
+ entityType: E.customers.customer_tag,
38
+ }
30
39
 
31
40
  const tagCrudEvents: CrudEventsConfig = {
32
41
  module: 'customers',
@@ -274,6 +283,7 @@ const updateTagCommand: CommandHandler<TagUpdateInput, { tagId: string }> = {
274
283
  tenantId: tag.tenantId,
275
284
  },
276
285
  events: tagCrudEvents,
286
+ indexer: tagCrudIndexer,
277
287
  })
278
288
  },
279
289
  }
@@ -373,6 +383,7 @@ const deleteTagCommand: CommandHandler<{ body?: Record<string, unknown>; query?:
373
383
  tenantId: tag.tenantId,
374
384
  },
375
385
  events: tagCrudEvents,
386
+ indexer: tagCrudIndexer,
376
387
  })
377
388
  },
378
389
  }
@@ -47,7 +47,7 @@ function resolveObjectLabels(objects: MessageObject[]): string[] {
47
47
  return objects.map((item) => `${item.entityModule}.${item.entityType} (${item.entityId})`)
48
48
  }
49
49
 
50
- type ResendAttachment = {
50
+ type EmailAttachment = {
51
51
  filename: string
52
52
  content: string
53
53
  contentType?: string
@@ -56,8 +56,8 @@ type ResendAttachment = {
56
56
  async function mapAttachmentsForEmail(
57
57
  messageId: string,
58
58
  attachments: MessageEmailAttachment[],
59
- ): Promise<ResendAttachment[]> {
60
- const resendAttachments: ResendAttachment[] = []
59
+ ): Promise<EmailAttachment[]> {
60
+ const emailAttachments: EmailAttachment[] = []
61
61
  let totalBytes = 0
62
62
 
63
63
  for (const attachment of attachments.slice(0, MAX_EMAIL_ATTACHMENTS)) {
@@ -89,14 +89,14 @@ async function mapAttachmentsForEmail(
89
89
  }
90
90
 
91
91
  totalBytes += buffer.length
92
- resendAttachments.push({
92
+ emailAttachments.push({
93
93
  filename: attachment.fileName,
94
94
  content: buffer.toString('base64'),
95
95
  contentType: attachment.mimeType || undefined,
96
96
  })
97
97
  }
98
98
 
99
- return resendAttachments
99
+ return emailAttachments
100
100
  }
101
101
 
102
102
  async function renderMarkdownEmailBody(body: string) {
@@ -178,14 +178,13 @@ export async function sendMessageEmailToRecipient(params: {
178
178
  }
179
179
  const copy = await buildEmailCopy(message.sentAt ?? new Date())
180
180
  const bodyHtml = await buildEmailBodyHtml(message)
181
- const resendAttachments = await mapAttachmentsForEmail(message.id, attachments)
182
- logDebug('Sending recipient email via Resend', {
181
+ const emailAttachments = await mapAttachmentsForEmail(message.id, attachments)
182
+ logDebug('Sending recipient email', {
183
183
  messageId: message.id,
184
184
  recipientUserId,
185
185
  recipientEmail,
186
186
  hasViewUrl: Boolean(viewUrl),
187
- attachmentsCount: resendAttachments.length,
188
- hasApiKey: Boolean(process.env.RESEND_API_KEY),
187
+ attachmentsCount: emailAttachments.length,
189
188
  from: resolveDefaultEmailFromAddress() ?? null,
190
189
  })
191
190
 
@@ -203,7 +202,9 @@ export async function sendMessageEmailToRecipient(params: {
203
202
  attachmentNames: attachments.map((item) => item.fileName),
204
203
  objectLabels: resolveObjectLabels(objects),
205
204
  }),
206
- attachments: resendAttachments,
205
+ attachments: emailAttachments,
206
+ tenantId: message.tenantId,
207
+ organizationId: message.organizationId ?? null,
207
208
  })
208
209
  }
209
210
 
@@ -217,12 +218,11 @@ export async function sendMessageEmailToExternal(params: {
217
218
  const { message, email, sender, objects, attachments } = params
218
219
  const copy = await buildEmailCopy(message.sentAt ?? new Date())
219
220
  const bodyHtml = await buildEmailBodyHtml(message)
220
- const resendAttachments = await mapAttachmentsForEmail(message.id, attachments)
221
- logDebug('Sending external email via Resend', {
221
+ const emailAttachments = await mapAttachmentsForEmail(message.id, attachments)
222
+ logDebug('Sending external email', {
222
223
  messageId: message.id,
223
224
  email,
224
- attachmentsCount: resendAttachments.length,
225
- hasApiKey: Boolean(process.env.RESEND_API_KEY),
225
+ attachmentsCount: emailAttachments.length,
226
226
  from: resolveDefaultEmailFromAddress() ?? null,
227
227
  })
228
228
 
@@ -240,9 +240,11 @@ export async function sendMessageEmailToExternal(params: {
240
240
  attachmentNames: attachments.map((item) => item.fileName),
241
241
  objectLabels: resolveObjectLabels(objects),
242
242
  }),
243
- attachments: resendAttachments,
243
+ attachments: emailAttachments,
244
+ tenantId: message.tenantId,
245
+ organizationId: message.organizationId ?? null,
244
246
  })
245
- logDebug('External email sent via Resend', {
247
+ logDebug('External email sent', {
246
248
  messageId: message.id,
247
249
  email,
248
250
  })
@@ -24,7 +24,7 @@ export const emailDeliveryStrategy: NotificationDeliveryStrategy = {
24
24
  defaultEnabled: true,
25
25
  isConfigured: (ctx: NotificationDeliveryContext) => ctx.deliveryConfig.strategies.email.enabled === true,
26
26
  async deliver(ctx: NotificationDeliveryContext) {
27
- const { recipient, panelLink, title, body, actionLinks, deliveryConfig, t } = ctx
27
+ const { notification, recipient, panelLink, title, body, actionLinks, deliveryConfig, t } = ctx
28
28
  if (!recipient?.email || !panelLink) {
29
29
  debug('email skipped: missing recipient email or panelLink')
30
30
  return
@@ -48,6 +48,8 @@ export const emailDeliveryStrategy: NotificationDeliveryStrategy = {
48
48
  subject,
49
49
  from: deliveryConfig.strategies.email.from,
50
50
  replyTo: deliveryConfig.strategies.email.replyTo,
51
+ tenantId: notification.tenantId,
52
+ organizationId: notification.organizationId ?? null,
51
53
  react: NotificationEmail({
52
54
  title,
53
55
  body,
@@ -169,6 +169,8 @@ export async function POST(req: Request) {
169
169
  orderNumber,
170
170
  }),
171
171
  react: QuoteAcceptedAdminEmail({ orderUrl, copy }),
172
+ tenantId: quote.tenantId,
173
+ organizationId: quote.organizationId,
172
174
  })
173
175
  } catch (err) {
174
176
  logger.error('sales.quotes.accept.adminEmail failed', { err })
@@ -18,6 +18,7 @@ import crypto from 'node:crypto'
18
18
  import { withScopedPayload } from '../../utils'
19
19
  import { hashAuthToken } from '../../../../auth/lib/tokenHash'
20
20
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
21
+ import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
21
22
  import { SalesQuote } from '../../../data/entities'
22
23
  import { quoteSendSchema } from '../../../data/validators'
23
24
  import { sendEmail } from '@open-mercato/shared/lib/email/send'
@@ -112,8 +113,16 @@ async function resolveRequestContext(req: Request): Promise<RequestContext> {
112
113
  }
113
114
 
114
115
  function resolveQuoteEmail(quote: SalesQuote): string | null {
115
- const snapshot = quote.customerSnapshot && typeof quote.customerSnapshot === 'object' ? (quote.customerSnapshot as Record<string, unknown>) : null
116
- const metadata = quote.metadata && typeof quote.metadata === 'object' ? (quote.metadata as Record<string, unknown>) : null
116
+ const normalizeRecord = (value: unknown): Record<string, unknown> | null => {
117
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
118
+ if (typeof value !== 'string') return null
119
+ const parsed = parseDecryptedFieldValue(value)
120
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
121
+ ? parsed as Record<string, unknown>
122
+ : null
123
+ }
124
+ const snapshot = normalizeRecord(quote.customerSnapshot)
125
+ const metadata = normalizeRecord(quote.metadata)
117
126
  const contact = snapshot?.contact as Record<string, unknown> | undefined
118
127
  const customer = snapshot?.customer as Record<string, unknown> | undefined
119
128
  const candidate =
@@ -148,7 +157,9 @@ export async function POST(req: Request) {
148
157
  }
149
158
 
150
159
  const em = (ctx.container.resolve('em') as EntityManager).fork()
151
- const tenantScope = ctx.auth?.tenantId ? { tenantId: ctx.auth.tenantId } : undefined
160
+ const tenantScope = ctx.auth?.tenantId
161
+ ? { tenantId: ctx.auth.tenantId, organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null }
162
+ : undefined
152
163
  const quote = await findOneWithDecryption(em, SalesQuote, { id: input.quoteId, deletedAt: null }, {}, tenantScope)
153
164
  if (!quote) {
154
165
  throw notFound(translate('sales.documents.detail.error', 'Document not found or inaccessible.'))
@@ -216,6 +227,8 @@ export async function POST(req: Request) {
216
227
  to: email,
217
228
  subject: translate('sales.quotes.email.subject', 'Quote {quoteNumber}', { quoteNumber: quote.quoteNumber }),
218
229
  react: QuoteSentEmail({ url, copy }),
230
+ tenantId: quote.tenantId,
231
+ organizationId: quote.organizationId,
219
232
  })
220
233
 
221
234
  if (guardResult.afterSuccessCallbacks.length) {
@@ -294,6 +294,7 @@ const createTimeEntryCommand: CommandHandler<StaffTimeEntryCreateInput, { timeEn
294
294
  entity: entry,
295
295
  identifiers: { id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
296
296
  events: staffTimeEntryCrudEvents,
297
+ indexer: timeEntryCrudIndexer,
297
298
  })
298
299
  }
299
300
  },
@@ -464,6 +465,7 @@ const startTimerCommand: CommandHandler<StaffTimeEntryStartTimerInput, { timeEnt
464
465
  entity: entry,
465
466
  identifiers: { id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
466
467
  events: staffTimeEntryCrudEvents,
468
+ indexer: timeEntryCrudIndexer,
467
469
  })
468
470
  }
469
471
  },
@@ -283,6 +283,7 @@ const createTimeProjectCommand: CommandHandler<StaffTimeProjectCreateInput, { ti
283
283
  tenantId: project.tenantId,
284
284
  },
285
285
  events: staffTimeProjectCrudEvents,
286
+ indexer: timeProjectCrudIndexer,
286
287
  })
287
288
  }
288
289
  },
@@ -660,6 +661,7 @@ const assignTimeProjectMemberCommand: CommandHandler<StaffTimeProjectMemberAssig
660
661
  entity: member,
661
662
  identifiers: { id: member.id, organizationId: member.organizationId, tenantId: member.tenantId },
662
663
  events: staffTimeProjectMemberCrudEvents,
664
+ indexer: timeProjectMemberCrudIndexer,
663
665
  })
664
666
  }
665
667
  },