@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js +77 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
- package/dist/helpers/integration/crmFixtures.js +3 -0
- package/dist/helpers/integration/crmFixtures.js.map +2 -2
- package/dist/modules/auth/api/reset.js +7 -1
- package/dist/modules/auth/api/reset.js.map +2 -2
- package/dist/modules/auth/api/users/resend-invite/route.js +7 -1
- package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
- package/dist/modules/auth/commands/users.js +7 -1
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +101 -26
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +3 -3
- package/dist/modules/communication_channels/di.js +8 -0
- package/dist/modules/communication_channels/di.js.map +2 -2
- package/dist/modules/communication_channels/lib/ensure-system-email-channel.js +57 -0
- package/dist/modules/communication_channels/lib/ensure-system-email-channel.js.map +7 -0
- package/dist/modules/communication_channels/lib/system-email-provider-config.js +32 -0
- package/dist/modules/communication_channels/lib/system-email-provider-config.js.map +7 -0
- package/dist/modules/communication_channels/lib/system-email.js +190 -0
- package/dist/modules/communication_channels/lib/system-email.js.map +7 -0
- package/dist/modules/communication_channels/lib/test-seed.js +130 -1
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/communication_channels/setup.js +56 -1
- package/dist/modules/communication_channels/setup.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users-invite.js +1 -0
- package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/users-invite.js +1 -0
- package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/signup.js +8 -4
- package/dist/modules/customer_accounts/api/signup.js.map +2 -2
- package/dist/modules/customer_accounts/lib/invitationEmail.js +3 -1
- package/dist/modules/customer_accounts/lib/invitationEmail.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +3 -0
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/commands/tags.js +8 -2
- package/dist/modules/customers/commands/tags.js.map +2 -2
- package/dist/modules/messages/lib/email-sender.js +16 -14
- package/dist/modules/messages/lib/email-sender.js.map +2 -2
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
- package/dist/modules/sales/api/quotes/accept/route.js +3 -1
- package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
- package/dist/modules/sales/api/quotes/send/route.js +13 -4
- package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
- package/dist/modules/staff/commands/timesheets-entries.js +4 -2
- package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-projects.js +4 -2
- package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
- package/src/helpers/integration/crmFixtures.ts +4 -1
- package/src/modules/auth/api/reset.ts +7 -1
- package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
- package/src/modules/auth/commands/users.ts +7 -1
- package/src/modules/auth/i18n/de.json +4 -0
- package/src/modules/auth/i18n/en.json +4 -0
- package/src/modules/auth/i18n/es.json +4 -0
- package/src/modules/auth/i18n/ko.json +4 -0
- package/src/modules/auth/i18n/pl.json +4 -0
- package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
- package/src/modules/communication_channels/di.ts +8 -0
- package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
- package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
- package/src/modules/communication_channels/lib/system-email.ts +316 -0
- package/src/modules/communication_channels/lib/test-seed.ts +198 -0
- package/src/modules/communication_channels/setup.ts +91 -1
- package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
- package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
- package/src/modules/customer_accounts/api/signup.ts +6 -2
- package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
- package/src/modules/customers/api/interactions/route.ts +4 -0
- package/src/modules/customers/commands/tags.ts +12 -1
- package/src/modules/messages/lib/email-sender.ts +18 -16
- package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
- package/src/modules/sales/api/quotes/accept/route.ts +2 -0
- package/src/modules/sales/api/quotes/send/route.ts +16 -3
- package/src/modules/staff/commands/timesheets-entries.ts +2 -0
- 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 {
|
|
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
|
*
|
|
@@ -140,10 +140,12 @@ export async function POST(req: Request) {
|
|
|
140
140
|
),
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
60
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
182
|
-
logDebug('Sending recipient email
|
|
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:
|
|
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:
|
|
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
|
|
221
|
-
logDebug('Sending external email
|
|
221
|
+
const emailAttachments = await mapAttachmentsForEmail(message.id, attachments)
|
|
222
|
+
logDebug('Sending external email', {
|
|
222
223
|
messageId: message.id,
|
|
223
224
|
email,
|
|
224
|
-
attachmentsCount:
|
|
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:
|
|
243
|
+
attachments: emailAttachments,
|
|
244
|
+
tenantId: message.tenantId,
|
|
245
|
+
organizationId: message.organizationId ?? null,
|
|
244
246
|
})
|
|
245
|
-
logDebug('External email sent
|
|
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
|
|
116
|
-
|
|
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
|
|
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
|
},
|