@open-mercato/core 0.6.7-develop.6795.1.8a3f27921c → 0.6.7-develop.6824.1.21fff90682
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/generated/entities/customer_deal_person_link/index.js +2 -0
- package/dist/generated/entities/customer_deal_person_link/index.js.map +2 -2
- package/dist/generated/entities/customer_user_invitation/index.js +2 -0
- package/dist/generated/entities/customer_user_invitation/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +10 -9
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/tenantAccess.js +7 -0
- package/dist/modules/auth/lib/tenantAccess.js.map +2 -2
- package/dist/modules/business_rules/api/openmercato-call-options/route.js +85 -0
- package/dist/modules/business_rules/api/openmercato-call-options/route.js.map +7 -0
- package/dist/modules/business_rules/api/rules/route.js +71 -4
- package/dist/modules/business_rules/api/rules/route.js.map +2 -2
- package/dist/modules/business_rules/components/ActionBuilder.js +47 -4
- package/dist/modules/business_rules/components/ActionBuilder.js.map +3 -3
- package/dist/modules/business_rules/components/ActionRow.js +106 -16
- package/dist/modules/business_rules/components/ActionRow.js.map +2 -2
- package/dist/modules/business_rules/components/utils/actionValidation.js +6 -0
- package/dist/modules/business_rules/components/utils/actionValidation.js.map +2 -2
- package/dist/modules/business_rules/lib/action-executor.js +148 -0
- package/dist/modules/business_rules/lib/action-executor.js.map +2 -2
- package/dist/modules/business_rules/lib/openmercato-call-options-types.js +5 -0
- package/dist/modules/business_rules/lib/openmercato-call-options-types.js.map +7 -0
- package/dist/modules/business_rules/lib/openmercato-call-options.js +231 -0
- package/dist/modules/business_rules/lib/openmercato-call-options.js.map +7 -0
- package/dist/modules/business_rules/lib/rule-engine.js +5 -1
- package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
- package/dist/modules/communication_channels/api/post/webhook/[provider]/route.js +11 -1
- package/dist/modules/communication_channels/api/post/webhook/[provider]/route.js.map +2 -2
- package/dist/modules/communication_channels/api/post/webhooks/gmail/route.js +11 -6
- package/dist/modules/communication_channels/api/post/webhooks/gmail/route.js.map +2 -2
- package/dist/modules/configs/lib/upgrade-actions.js +12 -0
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users/[id].js +10 -1
- package/dist/modules/customer_accounts/api/admin/users/[id].js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users-invite.js +61 -4
- package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/users-invite.js +20 -2
- package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/data/entities.js +3 -0
- package/dist/modules/customer_accounts/data/entities.js.map +2 -2
- package/dist/modules/customer_accounts/data/validators.js +1 -0
- package/dist/modules/customer_accounts/data/validators.js.map +2 -2
- package/dist/modules/customer_accounts/emails/CustomerInvitationEmail.js +26 -0
- package/dist/modules/customer_accounts/emails/CustomerInvitationEmail.js.map +7 -0
- package/dist/modules/customer_accounts/lib/customerEntityOwnership.js +29 -3
- package/dist/modules/customer_accounts/lib/customerEntityOwnership.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerUrl.js +19 -10
- package/dist/modules/customer_accounts/lib/customerUrl.js.map +2 -2
- package/dist/modules/customer_accounts/lib/invitationEmail.js +35 -0
- package/dist/modules/customer_accounts/lib/invitationEmail.js.map +7 -0
- package/dist/modules/customer_accounts/migrations/Migration20260723120000_customer_accounts.js +13 -0
- package/dist/modules/customer_accounts/migrations/Migration20260723120000_customer_accounts.js.map +7 -0
- package/dist/modules/customer_accounts/services/customerInvitationService.js +32 -2
- package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
- package/dist/modules/customer_accounts/subscribers/autoLinkCrm.js +47 -7
- package/dist/modules/customer_accounts/subscribers/autoLinkCrm.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection-table.js +1 -1
- package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/people/route.js +4 -2
- package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/route.js +7 -3
- package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +11 -1
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +14 -8
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +21 -6
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +48 -10
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/people.js +2 -0
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js +26 -6
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/DealDetailTabs.js +30 -8
- package/dist/modules/customers/components/detail/DealDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js +30 -8
- package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
- package/dist/modules/customers/data/entities.js +8 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +2 -1
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/migrations/Migration20260723210000_customers.js +15 -0
- package/dist/modules/customers/migrations/Migration20260723210000_customers.js.map +7 -0
- package/dist/modules/directory/api/tenants/route.js +8 -2
- package/dist/modules/directory/api/tenants/route.js.map +2 -2
- package/dist/modules/directory/commands/tenants.js +6 -0
- package/dist/modules/directory/commands/tenants.js.map +2 -2
- package/dist/modules/entities/api/entity-settings.js +59 -9
- package/dist/modules/entities/api/entity-settings.js.map +2 -2
- package/dist/modules/entities/backend/config/settings/page.js +10 -0
- package/dist/modules/entities/backend/config/settings/page.js.map +7 -0
- package/dist/modules/entities/backend/config/settings/page.meta.js +36 -0
- package/dist/modules/entities/backend/config/settings/page.meta.js.map +7 -0
- package/dist/modules/entities/backend/entities/user/create/page.js +16 -7
- package/dist/modules/entities/backend/entities/user/create/page.js.map +2 -2
- package/dist/modules/entities/components/EntitySettingsManager.js +136 -0
- package/dist/modules/entities/components/EntitySettingsManager.js.map +7 -0
- package/dist/modules/inbox_ops/api/webhook/inbound.js +14 -10
- package/dist/modules/inbox_ops/api/webhook/inbound.js.map +2 -2
- package/dist/modules/payment_gateways/api/sessions/route.js +1 -3
- package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +11 -1
- package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
- package/dist/modules/payment_gateways/data/entities.js +4 -0
- package/dist/modules/payment_gateways/data/entities.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +15 -6
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/session-idempotency.js +33 -0
- package/dist/modules/payment_gateways/lib/session-idempotency.js.map +2 -2
- package/dist/modules/payment_gateways/migrations/Migration20260803103035_payment_gateways.js +18 -0
- package/dist/modules/payment_gateways/migrations/Migration20260803103035_payment_gateways.js.map +7 -0
- package/dist/modules/payment_gateways/setup.js +47 -0
- package/dist/modules/payment_gateways/setup.js.map +2 -2
- package/dist/modules/payment_gateways/workers/session-initialization-prune.js +50 -0
- package/dist/modules/payment_gateways/workers/session-initialization-prune.js.map +7 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js +134 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.js.map +7 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.meta.js +11 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/invite/page.meta.js.map +7 -0
- package/dist/modules/portal/lib/navigation.js +7 -0
- package/dist/modules/portal/lib/navigation.js.map +7 -0
- package/dist/modules/query_index/lib/engine.js +43 -0
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +15 -5
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js +11 -1
- package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js.map +2 -2
- package/generated/entities/customer_deal_person_link/index.ts +1 -0
- package/generated/entities/customer_user_invitation/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/modules/auth/lib/backendChrome.tsx +15 -9
- package/src/modules/auth/lib/tenantAccess.ts +8 -0
- package/src/modules/business_rules/api/openmercato-call-options/route.ts +94 -0
- package/src/modules/business_rules/api/rules/route.ts +92 -4
- package/src/modules/business_rules/components/ActionBuilder.tsx +54 -5
- package/src/modules/business_rules/components/ActionRow.tsx +156 -18
- package/src/modules/business_rules/components/utils/actionValidation.ts +6 -0
- package/src/modules/business_rules/i18n/de.json +12 -0
- package/src/modules/business_rules/i18n/en.json +12 -0
- package/src/modules/business_rules/i18n/es.json +12 -0
- package/src/modules/business_rules/i18n/pl.json +12 -0
- package/src/modules/business_rules/lib/action-executor.ts +198 -0
- package/src/modules/business_rules/lib/openmercato-call-options-types.ts +31 -0
- package/src/modules/business_rules/lib/openmercato-call-options.ts +297 -0
- package/src/modules/business_rules/lib/rule-engine.ts +4 -0
- package/src/modules/communication_channels/api/post/webhook/[provider]/route.ts +11 -1
- package/src/modules/communication_channels/api/post/webhooks/gmail/route.ts +17 -11
- package/src/modules/configs/lib/upgrade-actions.ts +14 -0
- package/src/modules/customer_accounts/api/admin/users/[id].ts +16 -1
- package/src/modules/customer_accounts/api/admin/users-invite.ts +78 -3
- package/src/modules/customer_accounts/api/portal/users-invite.ts +22 -1
- package/src/modules/customer_accounts/data/entities.ts +3 -0
- package/src/modules/customer_accounts/data/validators.ts +1 -0
- package/src/modules/customer_accounts/emails/CustomerInvitationEmail.tsx +45 -0
- package/src/modules/customer_accounts/i18n/de.json +6 -0
- package/src/modules/customer_accounts/i18n/en.json +6 -0
- package/src/modules/customer_accounts/i18n/es.json +6 -0
- package/src/modules/customer_accounts/i18n/pl.json +6 -0
- package/src/modules/customer_accounts/lib/customerEntityOwnership.ts +68 -2
- package/src/modules/customer_accounts/lib/customerUrl.ts +27 -11
- package/src/modules/customer_accounts/lib/invitationEmail.ts +42 -0
- package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customer_accounts/migrations/Migration20260723120000_customer_accounts.ts +13 -0
- package/src/modules/customer_accounts/services/customerInvitationService.ts +56 -3
- package/src/modules/customer_accounts/subscribers/autoLinkCrm.ts +75 -8
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +2 -2
- package/src/modules/customer_accounts/widgets/injection-table.ts +4 -1
- package/src/modules/customers/api/deals/[id]/people/route.ts +3 -0
- package/src/modules/customers/api/deals/[id]/route.ts +10 -2
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +12 -1
- package/src/modules/customers/backend/customers/deals/[id]/hooks/types.ts +1 -0
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +15 -9
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +24 -6
- package/src/modules/customers/commands/deals.ts +50 -9
- package/src/modules/customers/commands/people.ts +3 -0
- package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +30 -7
- package/src/modules/customers/components/detail/DealDetailTabs.tsx +34 -9
- package/src/modules/customers/components/detail/PersonDetailTabs.tsx +34 -9
- package/src/modules/customers/data/entities.ts +9 -1
- package/src/modules/customers/data/validators.ts +1 -0
- package/src/modules/customers/i18n/de.json +1 -0
- package/src/modules/customers/i18n/en.json +1 -0
- package/src/modules/customers/i18n/es.json +1 -0
- package/src/modules/customers/i18n/pl.json +1 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +27 -1
- package/src/modules/customers/migrations/Migration20260723210000_customers.ts +15 -0
- package/src/modules/directory/api/tenants/route.ts +9 -1
- package/src/modules/directory/commands/tenants.ts +6 -0
- package/src/modules/entities/api/entity-settings.ts +66 -22
- package/src/modules/entities/backend/config/settings/page.meta.ts +34 -0
- package/src/modules/entities/backend/config/settings/page.tsx +12 -0
- package/src/modules/entities/backend/entities/user/create/page.tsx +16 -7
- package/src/modules/entities/components/EntitySettingsManager.tsx +160 -0
- package/src/modules/entities/i18n/de.json +9 -0
- package/src/modules/entities/i18n/en.json +9 -0
- package/src/modules/entities/i18n/es.json +9 -0
- package/src/modules/entities/i18n/pl.json +9 -0
- package/src/modules/inbox_ops/api/webhook/inbound.ts +14 -12
- package/src/modules/payment_gateways/api/sessions/route.ts +1 -3
- package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +13 -1
- package/src/modules/payment_gateways/data/entities.ts +5 -0
- package/src/modules/payment_gateways/i18n/de.json +5 -1
- package/src/modules/payment_gateways/i18n/en.json +5 -1
- package/src/modules/payment_gateways/i18n/es.json +5 -1
- package/src/modules/payment_gateways/i18n/pl.json +5 -1
- package/src/modules/payment_gateways/lib/gateway-service.ts +17 -8
- package/src/modules/payment_gateways/lib/session-idempotency.ts +36 -0
- package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +10 -1
- package/src/modules/payment_gateways/migrations/Migration20260803103035_payment_gateways.ts +17 -0
- package/src/modules/payment_gateways/setup.ts +62 -0
- package/src/modules/payment_gateways/workers/session-initialization-prune.ts +63 -0
- package/src/modules/portal/frontend/[orgSlug]/portal/invite/page.meta.ts +9 -0
- package/src/modules/portal/frontend/[orgSlug]/portal/invite/page.tsx +164 -0
- package/src/modules/portal/i18n/de.json +18 -0
- package/src/modules/portal/i18n/en.json +18 -0
- package/src/modules/portal/i18n/es.json +18 -0
- package/src/modules/portal/i18n/pl.json +18 -0
- package/src/modules/portal/lib/navigation.ts +3 -0
- package/src/modules/query_index/lib/engine.ts +52 -0
- package/src/modules/query_index/lib/search-tokens.ts +16 -4
- package/src/modules/shipping_carriers/api/webhook/[provider]/route.ts +11 -1
|
@@ -91,6 +91,20 @@ export const upgradeActions: UpgradeActionDefinition[] = [
|
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
},
|
|
94
|
+
{
|
|
95
|
+
id: 'payment_gateways.register-session-initialization-prune',
|
|
96
|
+
version: '0.6.6',
|
|
97
|
+
messageKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.message',
|
|
98
|
+
ctaKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.cta',
|
|
99
|
+
successKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.success',
|
|
100
|
+
loadingKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.loading',
|
|
101
|
+
run: async ({ container, tenantId, organizationId }) => {
|
|
102
|
+
const { registerSessionInitializationPruneSchedule } = await import(
|
|
103
|
+
'@open-mercato/core/modules/payment_gateways/setup'
|
|
104
|
+
)
|
|
105
|
+
await registerSessionInitializationPruneSchedule(container, { tenantId, organizationId })
|
|
106
|
+
},
|
|
107
|
+
},
|
|
94
108
|
]
|
|
95
109
|
|
|
96
110
|
export function actionsUpToVersion(version: string): UpgradeActionDefinition[] {
|
|
@@ -11,7 +11,7 @@ import { CustomerRbacService } from '@open-mercato/core/modules/customer_account
|
|
|
11
11
|
import { adminUpdateUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
|
|
12
12
|
import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
|
|
13
13
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
14
|
-
import { isOwnedCompanyEntity } from '@open-mercato/core/modules/customer_accounts/lib/customerEntityOwnership'
|
|
14
|
+
import { isOwnedCompanyEntity, isOwnedPersonEntity } from '@open-mercato/core/modules/customer_accounts/lib/customerEntityOwnership'
|
|
15
15
|
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
16
16
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
17
17
|
|
|
@@ -168,6 +168,21 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// Same guard for the person FK. Without it the invite-side check is trivially
|
|
172
|
+
// bypassable: create the user normally, then PUT an unowned personEntityId.
|
|
173
|
+
// It persists (autoLinkCrm short-circuits on any non-null value) and leaks
|
|
174
|
+
// account status into the other org's people list via the account-status
|
|
175
|
+
// enricher. A null value (unlink) needs no ownership check.
|
|
176
|
+
if (parsed.data.personEntityId) {
|
|
177
|
+
const owned = await isOwnedPersonEntity(em, parsed.data.personEntityId, {
|
|
178
|
+
tenantId: auth.tenantId,
|
|
179
|
+
organizationId: auth.orgId,
|
|
180
|
+
})
|
|
181
|
+
if (!owned) {
|
|
182
|
+
return NextResponse.json({ ok: false, error: 'Person not found' }, { status: 400 })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
171
186
|
// Always bump updated_at so the optimistic-lock version advances on every save.
|
|
172
187
|
// `nativeUpdate` bypasses MikroORM's `onUpdate` hook, so set it explicitly — without
|
|
173
188
|
// this the version never changes and concurrent edits cannot be detected (#2055).
|
|
@@ -7,6 +7,7 @@ import { RbacService } from '@open-mercato/core/modules/auth/services/rbacServic
|
|
|
7
7
|
import { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
|
|
8
8
|
import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
|
|
9
9
|
import { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
|
|
10
|
+
import { isOwnedCompanyEntity, isOwnedPersonEntity, resolveOwnedCompanyForPerson } from '@open-mercato/core/modules/customer_accounts/lib/customerEntityOwnership'
|
|
10
11
|
import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
11
12
|
import {
|
|
12
13
|
checkAuthRateLimit,
|
|
@@ -14,9 +15,15 @@ import {
|
|
|
14
15
|
customerInviteIpRateLimitConfig,
|
|
15
16
|
} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'
|
|
16
17
|
import { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'
|
|
18
|
+
import { sendCustomerInvitationEmail } from '@open-mercato/core/modules/customer_accounts/lib/invitationEmail'
|
|
17
19
|
|
|
18
20
|
export const metadata = {}
|
|
19
21
|
|
|
22
|
+
function resolveInvitedByUserId(auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>): string | null {
|
|
23
|
+
if (auth.isApiKey) return auth.userId ?? null
|
|
24
|
+
return auth.sub
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export async function POST(req: Request) {
|
|
21
28
|
const auth = await getAuthFromRequest(req)
|
|
22
29
|
if (!auth) {
|
|
@@ -51,19 +58,80 @@ export async function POST(req: Request) {
|
|
|
51
58
|
return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
// Reject a customerEntityId the caller does not own. customerEntityId is the
|
|
62
|
+
// CRM company FK; without this check a non-company (e.g. a person) entity id
|
|
63
|
+
// or a company from another org poisons the invitation and every later user
|
|
64
|
+
// edit fails with "Company not found" (#4362, #2693).
|
|
65
|
+
if (parsed.data.customerEntityId) {
|
|
66
|
+
const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager
|
|
67
|
+
const owned = await isOwnedCompanyEntity(em, parsed.data.customerEntityId, {
|
|
68
|
+
tenantId: auth.tenantId,
|
|
69
|
+
organizationId: auth.orgId,
|
|
70
|
+
})
|
|
71
|
+
if (!owned) {
|
|
72
|
+
return NextResponse.json({ ok: false, error: 'Company not found' }, { status: 400 })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Same guard for the person FK: it is copied onto the customer user on accept
|
|
77
|
+
// and makes `autoLinkCrm` short-circuit, so an unowned id would permanently
|
|
78
|
+
// cross-link the portal user to another org's CRM person.
|
|
79
|
+
let resolvedCustomerEntityId = parsed.data.customerEntityId || null
|
|
80
|
+
if (parsed.data.personEntityId) {
|
|
81
|
+
const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager
|
|
82
|
+
const owned = await isOwnedPersonEntity(em, parsed.data.personEntityId, {
|
|
83
|
+
tenantId: auth.tenantId,
|
|
84
|
+
organizationId: auth.orgId,
|
|
85
|
+
})
|
|
86
|
+
if (!owned) {
|
|
87
|
+
return NextResponse.json({ ok: false, error: 'Person not found' }, { status: 400 })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// An invitation raised from a person card carries only the person FK, and the
|
|
91
|
+
// accepted user then short-circuits `autoLinkCrm`. Resolve the person's company
|
|
92
|
+
// here so the user lands with a company scope key: the portal Users page, portal
|
|
93
|
+
// invitations, and the company detail "Portal users" group all filter on it.
|
|
94
|
+
if (!resolvedCustomerEntityId) {
|
|
95
|
+
resolvedCustomerEntityId = await resolveOwnedCompanyForPerson(em, parsed.data.personEntityId, {
|
|
96
|
+
tenantId: auth.tenantId,
|
|
97
|
+
organizationId: auth.orgId,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
54
102
|
const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService
|
|
55
103
|
|
|
56
|
-
const { invitation } = await customerInvitationService.createInvitation(
|
|
104
|
+
const { invitation, rawToken, rollbackState } = await customerInvitationService.createInvitation(
|
|
57
105
|
parsed.data.email,
|
|
58
106
|
{ tenantId: auth.tenantId!, organizationId: auth.orgId! },
|
|
59
107
|
{
|
|
60
|
-
customerEntityId:
|
|
108
|
+
customerEntityId: resolvedCustomerEntityId,
|
|
109
|
+
personEntityId: parsed.data.personEntityId || null,
|
|
61
110
|
roleIds: parsed.data.roleIds,
|
|
62
|
-
invitedByUserId: auth
|
|
111
|
+
invitedByUserId: resolveInvitedByUserId(auth),
|
|
63
112
|
displayName: parsed.data.displayName || null,
|
|
64
113
|
},
|
|
65
114
|
)
|
|
66
115
|
|
|
116
|
+
try {
|
|
117
|
+
await sendCustomerInvitationEmail({
|
|
118
|
+
container,
|
|
119
|
+
organizationId: auth.orgId!,
|
|
120
|
+
email: invitation.email,
|
|
121
|
+
rawToken,
|
|
122
|
+
})
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error('[customer_accounts.admin.users-invite] invitation email failed', error)
|
|
125
|
+
try {
|
|
126
|
+
await customerInvitationService.rollbackInvitation(invitation, rollbackState)
|
|
127
|
+
} catch (rollbackError) {
|
|
128
|
+
console.error('[customer_accounts.admin.users-invite] invitation rollback failed', rollbackError)
|
|
129
|
+
}
|
|
130
|
+
return NextResponse.json({ ok: false, error: 'Invitation email could not be sent' }, { status: 502 })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Emit only after the email is sent, so a subscriber observing "invited" can
|
|
134
|
+
// assume the recipient was actually notified (no event fires on the 502 path).
|
|
67
135
|
void emitCustomerAccountsEvent('customer_accounts.user.invited', {
|
|
68
136
|
invitationId: invitation.id,
|
|
69
137
|
email: invitation.email,
|
|
@@ -78,6 +146,10 @@ export async function POST(req: Request) {
|
|
|
78
146
|
invitation: {
|
|
79
147
|
id: invitation.id,
|
|
80
148
|
email: invitation.email,
|
|
149
|
+
// Echo the stored CRM links so the caller can see which company the person
|
|
150
|
+
// invite resolved to. The token stays unexposed.
|
|
151
|
+
customerEntityId: invitation.customerEntityId || null,
|
|
152
|
+
personEntityId: invitation.personEntityId || null,
|
|
81
153
|
expiresAt: invitation.expiresAt,
|
|
82
154
|
},
|
|
83
155
|
}, { status: 201 })
|
|
@@ -88,6 +160,8 @@ const successSchema = z.object({
|
|
|
88
160
|
invitation: z.object({
|
|
89
161
|
id: z.string().uuid(),
|
|
90
162
|
email: z.string(),
|
|
163
|
+
customerEntityId: z.string().uuid().nullable(),
|
|
164
|
+
personEntityId: z.string().uuid().nullable(),
|
|
91
165
|
expiresAt: z.string().datetime(),
|
|
92
166
|
}),
|
|
93
167
|
})
|
|
@@ -104,6 +178,7 @@ const methodDoc: OpenApiMethodDoc = {
|
|
|
104
178
|
{ status: 401, description: 'Not authenticated', schema: errorSchema },
|
|
105
179
|
{ status: 403, description: 'Insufficient permissions', schema: errorSchema },
|
|
106
180
|
{ status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },
|
|
181
|
+
{ status: 502, description: 'Invitation email could not be sent', schema: errorSchema },
|
|
107
182
|
],
|
|
108
183
|
}
|
|
109
184
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
customerInviteIpRateLimitConfig,
|
|
17
17
|
} from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'
|
|
18
18
|
import { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'
|
|
19
|
+
import { sendCustomerInvitationEmail } from '@open-mercato/core/modules/customer_accounts/lib/invitationEmail'
|
|
19
20
|
|
|
20
21
|
export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }
|
|
21
22
|
|
|
@@ -90,7 +91,7 @@ export async function POST(req: Request) {
|
|
|
90
91
|
|
|
91
92
|
const customerInvitationService = container.resolve('customerInvitationService') as CustomerInvitationService
|
|
92
93
|
|
|
93
|
-
const { invitation } = await customerInvitationService.createInvitation(
|
|
94
|
+
const { invitation, rawToken, rollbackState } = await customerInvitationService.createInvitation(
|
|
94
95
|
parsed.data.email,
|
|
95
96
|
{ tenantId: auth.tenantId, organizationId: auth.orgId },
|
|
96
97
|
{
|
|
@@ -101,6 +102,25 @@ export async function POST(req: Request) {
|
|
|
101
102
|
},
|
|
102
103
|
)
|
|
103
104
|
|
|
105
|
+
try {
|
|
106
|
+
await sendCustomerInvitationEmail({
|
|
107
|
+
container,
|
|
108
|
+
organizationId: auth.orgId,
|
|
109
|
+
email: invitation.email,
|
|
110
|
+
rawToken,
|
|
111
|
+
})
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error('[customer_accounts.portal.users-invite] invitation email failed', error)
|
|
114
|
+
try {
|
|
115
|
+
await customerInvitationService.rollbackInvitation(invitation, rollbackState)
|
|
116
|
+
} catch (rollbackError) {
|
|
117
|
+
console.error('[customer_accounts.portal.users-invite] invitation rollback failed', rollbackError)
|
|
118
|
+
}
|
|
119
|
+
return NextResponse.json({ ok: false, error: 'Invitation email could not be sent' }, { status: 502 })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Emit only after the email is sent, so a subscriber observing "invited" can
|
|
123
|
+
// assume the recipient was actually notified (no event fires on the 502 path).
|
|
104
124
|
void emitCustomerAccountsEvent('customer_accounts.user.invited', {
|
|
105
125
|
invitationId: invitation.id,
|
|
106
126
|
email: invitation.email,
|
|
@@ -141,6 +161,7 @@ const methodDoc: OpenApiMethodDoc = {
|
|
|
141
161
|
{ status: 401, description: 'Not authenticated', schema: errorSchema },
|
|
142
162
|
{ status: 403, description: 'Insufficient permissions or non-assignable role', schema: errorSchema },
|
|
143
163
|
{ status: 429, description: 'Too many invitation requests', schema: rateLimitErrorSchema },
|
|
164
|
+
{ status: 502, description: 'Invitation email could not be sent', schema: errorSchema },
|
|
144
165
|
],
|
|
145
166
|
}
|
|
146
167
|
|
|
@@ -291,6 +291,9 @@ export class CustomerUserInvitation {
|
|
|
291
291
|
@Property({ name: 'customer_entity_id', type: 'uuid', nullable: true })
|
|
292
292
|
customerEntityId?: string | null
|
|
293
293
|
|
|
294
|
+
@Property({ name: 'person_entity_id', type: 'uuid', nullable: true })
|
|
295
|
+
personEntityId?: string | null
|
|
296
|
+
|
|
294
297
|
@Property({ name: 'role_ids_json', type: 'json', nullable: true })
|
|
295
298
|
roleIdsJson?: string[] | null
|
|
296
299
|
|
|
@@ -81,6 +81,7 @@ export const updateRoleAclSchema = z.object({
|
|
|
81
81
|
export const inviteUserSchema = z.object({
|
|
82
82
|
email: emailField,
|
|
83
83
|
customerEntityId: z.string().uuid().optional(),
|
|
84
|
+
personEntityId: z.string().uuid().optional(),
|
|
84
85
|
roleIds: z.array(z.string().uuid()).min(1),
|
|
85
86
|
displayName: displayNameField.optional(),
|
|
86
87
|
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Html, Head, Preview, Body, Container, Text, Section, Link } from '@react-email/components'
|
|
3
|
+
|
|
4
|
+
export type CustomerInvitationEmailCopy = {
|
|
5
|
+
preview: string
|
|
6
|
+
title: string
|
|
7
|
+
body: string
|
|
8
|
+
cta: string
|
|
9
|
+
hint: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type CustomerInvitationEmailProps = {
|
|
13
|
+
inviteUrl: string
|
|
14
|
+
copy: CustomerInvitationEmailCopy
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function CustomerInvitationEmail({ inviteUrl, copy }: CustomerInvitationEmailProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Html>
|
|
20
|
+
<Head />
|
|
21
|
+
<Preview>{copy.preview}</Preview>
|
|
22
|
+
<Body style={body}>
|
|
23
|
+
<Container style={container}>
|
|
24
|
+
<Section>
|
|
25
|
+
<Text style={title}>{copy.title}</Text>
|
|
26
|
+
<Text style={paragraph}>{copy.body}</Text>
|
|
27
|
+
<Text>
|
|
28
|
+
<Link href={inviteUrl} style={button}>{copy.cta}</Link>
|
|
29
|
+
</Text>
|
|
30
|
+
<Text style={hint}>{copy.hint}</Text>
|
|
31
|
+
</Section>
|
|
32
|
+
</Container>
|
|
33
|
+
</Body>
|
|
34
|
+
</Html>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const body: React.CSSProperties = { backgroundColor: '#f9fafb', margin: 0, padding: '24px 0' }
|
|
39
|
+
const container: React.CSSProperties = { backgroundColor: '#ffffff', borderRadius: 12, padding: 24, margin: '0 auto', maxWidth: 520, boxShadow: '0 1px 4px rgba(0,0,0,0.06)' }
|
|
40
|
+
const title: React.CSSProperties = { fontSize: 20, fontWeight: 600, color: '#111827', margin: '0 0 12px' }
|
|
41
|
+
const paragraph: React.CSSProperties = { fontSize: 14, color: '#4b5563', lineHeight: '20px', margin: '0 0 16px' }
|
|
42
|
+
const button: React.CSSProperties = { display: 'inline-block', backgroundColor: '#111827', color: '#ffffff', padding: '10px 14px', borderRadius: 8, textDecoration: 'none', fontSize: 14 }
|
|
43
|
+
const hint: React.CSSProperties = { fontSize: 12, color: '#6b7280', marginTop: 16 }
|
|
44
|
+
|
|
45
|
+
export default CustomerInvitationEmail
|
|
@@ -274,6 +274,12 @@
|
|
|
274
274
|
"customer_accounts.domainMapping.tls.diagnostics.title": "SSL certificate issue",
|
|
275
275
|
"customer_accounts.domainMapping.tls.retryButton": "Retry SSL",
|
|
276
276
|
"customer_accounts.domainMapping.verifyNow": "Check Now",
|
|
277
|
+
"customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
|
|
278
|
+
"customer_accounts.invitation.email.cta": "Accept invitation",
|
|
279
|
+
"customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
|
|
280
|
+
"customer_accounts.invitation.email.preview": "Accept your invitation to finish setting up portal access.",
|
|
281
|
+
"customer_accounts.invitation.email.subject": "You have been invited to the customer portal",
|
|
282
|
+
"customer_accounts.invitation.email.title": "You have been invited",
|
|
277
283
|
"customer_accounts.nav.group": "Customers",
|
|
278
284
|
"customer_accounts.nav.role_create": "Rolle erstellen",
|
|
279
285
|
"customer_accounts.nav.role_detail": "Rollendetails",
|
|
@@ -274,6 +274,12 @@
|
|
|
274
274
|
"customer_accounts.domainMapping.tls.diagnostics.title": "SSL certificate issue",
|
|
275
275
|
"customer_accounts.domainMapping.tls.retryButton": "Retry SSL",
|
|
276
276
|
"customer_accounts.domainMapping.verifyNow": "Check Now",
|
|
277
|
+
"customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
|
|
278
|
+
"customer_accounts.invitation.email.cta": "Accept invitation",
|
|
279
|
+
"customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
|
|
280
|
+
"customer_accounts.invitation.email.preview": "Accept your invitation to finish setting up portal access.",
|
|
281
|
+
"customer_accounts.invitation.email.subject": "You have been invited to the customer portal",
|
|
282
|
+
"customer_accounts.invitation.email.title": "You have been invited",
|
|
277
283
|
"customer_accounts.nav.group": "Customers",
|
|
278
284
|
"customer_accounts.nav.role_create": "Create Role",
|
|
279
285
|
"customer_accounts.nav.role_detail": "Role Detail",
|
|
@@ -274,6 +274,12 @@
|
|
|
274
274
|
"customer_accounts.domainMapping.tls.diagnostics.title": "SSL certificate issue",
|
|
275
275
|
"customer_accounts.domainMapping.tls.retryButton": "Retry SSL",
|
|
276
276
|
"customer_accounts.domainMapping.verifyNow": "Check Now",
|
|
277
|
+
"customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
|
|
278
|
+
"customer_accounts.invitation.email.cta": "Accept invitation",
|
|
279
|
+
"customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
|
|
280
|
+
"customer_accounts.invitation.email.preview": "Accept your invitation to finish setting up portal access.",
|
|
281
|
+
"customer_accounts.invitation.email.subject": "You have been invited to the customer portal",
|
|
282
|
+
"customer_accounts.invitation.email.title": "You have been invited",
|
|
277
283
|
"customer_accounts.nav.group": "Customers",
|
|
278
284
|
"customer_accounts.nav.role_create": "Create Role",
|
|
279
285
|
"customer_accounts.nav.role_detail": "Role Detail",
|
|
@@ -274,6 +274,12 @@
|
|
|
274
274
|
"customer_accounts.domainMapping.tls.diagnostics.title": "Problem z certyfikatem SSL",
|
|
275
275
|
"customer_accounts.domainMapping.tls.retryButton": "Ponów SSL",
|
|
276
276
|
"customer_accounts.domainMapping.verifyNow": "Sprawdź teraz",
|
|
277
|
+
"customer_accounts.invitation.email.body": "Użyj bezpiecznego linku zaproszenia, aby utworzyć hasło i uzyskać dostęp do portalu klienta.",
|
|
278
|
+
"customer_accounts.invitation.email.cta": "Przyjmij zaproszenie",
|
|
279
|
+
"customer_accounts.invitation.email.hint": "Ten link zaproszenia wygasa po 72 godzinach. Jeśli nie spodziewasz się tego zaproszenia, możesz zignorować tę wiadomość.",
|
|
280
|
+
"customer_accounts.invitation.email.preview": "Przyjmij zaproszenie, aby dokończyć konfigurację dostępu do portalu.",
|
|
281
|
+
"customer_accounts.invitation.email.subject": "Masz zaproszenie do portalu klienta",
|
|
282
|
+
"customer_accounts.invitation.email.title": "Masz zaproszenie",
|
|
277
283
|
"customer_accounts.nav.group": "Klienci",
|
|
278
284
|
"customer_accounts.nav.role_create": "Utwórz rolę",
|
|
279
285
|
"customer_accounts.nav.role_detail": "Szczegóły roli",
|
|
@@ -20,16 +20,82 @@ export async function isOwnedCompanyEntity(
|
|
|
20
20
|
em: EntityManager,
|
|
21
21
|
customerEntityId: string,
|
|
22
22
|
scope: CustomerEntityScope,
|
|
23
|
+
): Promise<boolean> {
|
|
24
|
+
return isOwnedEntityOfKind(em, customerEntityId, 'company', scope)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Confirms that a caller-supplied `personEntityId` (the CRM person FK linked
|
|
29
|
+
* onto a customer user) exists, is a person, is not soft-deleted, and belongs
|
|
30
|
+
* to the caller's tenant and organization.
|
|
31
|
+
*
|
|
32
|
+
* This is the symmetric guard to {@link isOwnedCompanyEntity}: the person FK is
|
|
33
|
+
* copied onto the customer user on accept and short-circuits CRM auto-linking,
|
|
34
|
+
* so an unvalidated id would permanently cross-link a portal user to another
|
|
35
|
+
* org's person (the #4362/#2693 class of bug, on the person side).
|
|
36
|
+
*/
|
|
37
|
+
export async function isOwnedPersonEntity(
|
|
38
|
+
em: EntityManager,
|
|
39
|
+
personEntityId: string,
|
|
40
|
+
scope: CustomerEntityScope,
|
|
41
|
+
): Promise<boolean> {
|
|
42
|
+
return isOwnedEntityOfKind(em, personEntityId, 'person', scope)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the CRM company a person belongs to, but only when both the profile
|
|
47
|
+
* lookup and the company itself stay inside the given tenant/organization scope.
|
|
48
|
+
* Returns null when the person has no company or that company is out of scope.
|
|
49
|
+
*
|
|
50
|
+
* Both the invite route and the CRM auto-link subscriber need this answer, and
|
|
51
|
+
* both write the result into `customerEntityId` — the portal company scope key —
|
|
52
|
+
* so they must agree on what counts as an in-scope company.
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveOwnedCompanyForPerson(
|
|
55
|
+
em: EntityManager,
|
|
56
|
+
personEntityId: string,
|
|
57
|
+
scope: CustomerEntityScope,
|
|
58
|
+
): Promise<string | null> {
|
|
59
|
+
const { CustomerPersonProfile } = await import('@open-mercato/core/modules/customers/data/entities')
|
|
60
|
+
const profile = await em.findOne(CustomerPersonProfile as any, {
|
|
61
|
+
entity: personEntityId,
|
|
62
|
+
tenantId: scope.tenantId,
|
|
63
|
+
organizationId: scope.organizationId,
|
|
64
|
+
} as any)
|
|
65
|
+
const candidate = readCompanyEntityId(profile)
|
|
66
|
+
if (!candidate) return null
|
|
67
|
+
return (await isOwnedCompanyEntity(em, candidate, scope)) ? candidate : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `CustomerPersonProfile.company` is a relation on `company_entity_id`, not a
|
|
72
|
+
* scalar — MikroORM hands back either the raw id or an entity reference, never a
|
|
73
|
+
* `companyEntityId` property. Reading the wrong one silently yields undefined and
|
|
74
|
+
* turns every company recovery into a clear.
|
|
75
|
+
*/
|
|
76
|
+
function readCompanyEntityId(profile: unknown): string | null {
|
|
77
|
+
const company = (profile as { company?: unknown } | null | undefined)?.company
|
|
78
|
+
if (!company) return null
|
|
79
|
+
if (typeof company === 'string') return company
|
|
80
|
+
const id = (company as { id?: unknown }).id
|
|
81
|
+
return typeof id === 'string' ? id : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function isOwnedEntityOfKind(
|
|
85
|
+
em: EntityManager,
|
|
86
|
+
entityId: string,
|
|
87
|
+
kind: 'company' | 'person',
|
|
88
|
+
scope: CustomerEntityScope,
|
|
23
89
|
): Promise<boolean> {
|
|
24
90
|
const { CustomerEntity } = await import('@open-mercato/core/modules/customers/data/entities')
|
|
25
91
|
const entity = await findOneWithDecryption(
|
|
26
92
|
em,
|
|
27
93
|
CustomerEntity,
|
|
28
94
|
{
|
|
29
|
-
id:
|
|
95
|
+
id: entityId,
|
|
30
96
|
tenantId: scope.tenantId,
|
|
31
97
|
organizationId: scope.organizationId,
|
|
32
|
-
kind
|
|
98
|
+
kind,
|
|
33
99
|
deletedAt: null,
|
|
34
100
|
} as any,
|
|
35
101
|
undefined,
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
7
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
|
+
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
9
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
8
10
|
|
|
9
11
|
type DomainMappingService = {
|
|
10
12
|
resolveActiveByOrg(orgId: string): Promise<{ hostname: string } | null>
|
|
@@ -14,6 +16,10 @@ type OrgService = {
|
|
|
14
16
|
findById(orgId: string): Promise<{ id: string; slug: string | null } | null>
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
type EntityManagerResolver = {
|
|
20
|
+
resolve(name: 'em'): EntityManager
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
function platformBaseUrl(): string {
|
|
18
24
|
const fromEnv = process.env.PLATFORM_PORTAL_BASE_URL?.trim()
|
|
19
25
|
if (fromEnv && fromEnv.length > 0) return fromEnv.replace(/\/+$/, '')
|
|
@@ -32,6 +38,26 @@ function resolveContainer(container?: AppContainer): Promise<AppContainer> {
|
|
|
32
38
|
return createRequestContainer()
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
async function resolveOrgSlug(container: AppContainer, orgId: string): Promise<string | null> {
|
|
42
|
+
try {
|
|
43
|
+
const orgService = container.resolve('orgService') as OrgService | undefined
|
|
44
|
+
if (orgService && typeof orgService.findById === 'function') {
|
|
45
|
+
const org = await orgService.findById(orgId)
|
|
46
|
+
return org?.slug ?? null
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// orgService is optional in standalone/runtime contexts — fall through to EntityManager.
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const em = (container as AppContainer & EntityManagerResolver).resolve('em')
|
|
54
|
+
const org = await em.findOne(Organization, { id: orgId, deletedAt: null })
|
|
55
|
+
return org?.slug ?? null
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
export async function urlForCustomerOrg(
|
|
36
62
|
orgId: string,
|
|
37
63
|
path: string,
|
|
@@ -55,17 +81,7 @@ export async function urlForCustomerOrg(
|
|
|
55
81
|
return `https://${active.hostname}${safePath}`
|
|
56
82
|
}
|
|
57
83
|
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const orgService = container.resolve('orgService') as OrgService | undefined
|
|
61
|
-
if (orgService && typeof orgService.findById === 'function') {
|
|
62
|
-
const org = await orgService.findById(orgId)
|
|
63
|
-
orgSlug = org?.slug ?? null
|
|
64
|
-
}
|
|
65
|
-
} catch {
|
|
66
|
-
orgSlug = null
|
|
67
|
-
}
|
|
68
|
-
|
|
84
|
+
const orgSlug = await resolveOrgSlug(container, orgId)
|
|
69
85
|
const base = platformBaseUrl()
|
|
70
86
|
if (orgSlug) return `${base}/${orgSlug}/portal${safePath}`
|
|
71
87
|
return `${base}${safePath}`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
2
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
3
|
+
import { sendEmail } from '@open-mercato/shared/lib/email/send'
|
|
4
|
+
import CustomerInvitationEmail from '@open-mercato/core/modules/customer_accounts/emails/CustomerInvitationEmail'
|
|
5
|
+
import { urlForCustomerOrg } from '@open-mercato/core/modules/customer_accounts/lib/customerUrl'
|
|
6
|
+
|
|
7
|
+
export type CustomerInvitationEmailInput = {
|
|
8
|
+
container: AppContainer
|
|
9
|
+
organizationId: string
|
|
10
|
+
email: string
|
|
11
|
+
rawToken: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function sendCustomerInvitationEmail(input: CustomerInvitationEmailInput): Promise<void> {
|
|
15
|
+
const { translate } = await resolveTranslations()
|
|
16
|
+
const inviteUrl = await urlForCustomerOrg(
|
|
17
|
+
input.organizationId,
|
|
18
|
+
`/invite?token=${encodeURIComponent(input.rawToken)}`,
|
|
19
|
+
{ container: input.container },
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const subject = translate('customer_accounts.invitation.email.subject', 'You have been invited to the customer portal')
|
|
23
|
+
const copy = {
|
|
24
|
+
preview: translate('customer_accounts.invitation.email.preview', 'Accept your invitation to finish setting up portal access.'),
|
|
25
|
+
title: translate('customer_accounts.invitation.email.title', 'You have been invited'),
|
|
26
|
+
body: translate(
|
|
27
|
+
'customer_accounts.invitation.email.body',
|
|
28
|
+
'Use this secure invitation link to create your password and access the customer portal.',
|
|
29
|
+
),
|
|
30
|
+
cta: translate('customer_accounts.invitation.email.cta', 'Accept invitation'),
|
|
31
|
+
hint: translate(
|
|
32
|
+
'customer_accounts.invitation.email.hint',
|
|
33
|
+
'This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.',
|
|
34
|
+
),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await sendEmail({
|
|
38
|
+
to: input.email,
|
|
39
|
+
subject,
|
|
40
|
+
react: CustomerInvitationEmail({ inviteUrl, copy }),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -923,6 +923,22 @@
|
|
|
923
923
|
"enumItems": [],
|
|
924
924
|
"mappedType": "uuid"
|
|
925
925
|
},
|
|
926
|
+
"person_entity_id": {
|
|
927
|
+
"name": "person_entity_id",
|
|
928
|
+
"type": "uuid",
|
|
929
|
+
"unsigned": false,
|
|
930
|
+
"autoincrement": false,
|
|
931
|
+
"primary": false,
|
|
932
|
+
"nullable": true,
|
|
933
|
+
"unique": false,
|
|
934
|
+
"length": null,
|
|
935
|
+
"precision": null,
|
|
936
|
+
"scale": null,
|
|
937
|
+
"default": null,
|
|
938
|
+
"comment": null,
|
|
939
|
+
"enumItems": [],
|
|
940
|
+
"mappedType": "uuid"
|
|
941
|
+
},
|
|
926
942
|
"role_ids_json": {
|
|
927
943
|
"name": "role_ids_json",
|
|
928
944
|
"type": "jsonb",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260723120000_customer_accounts extends Migration {
|
|
4
|
+
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(`alter table "customer_user_invitations" add column "person_entity_id" uuid null;`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
override async down(): Promise<void> {
|
|
10
|
+
this.addSql(`alter table "customer_user_invitations" drop column "person_entity_id";`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|