@open-mercato/core 0.7.1-develop.7154.1.981330c924 → 0.7.1-develop.7171.1.9b31dbba45

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 (155) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -2
  3. package/dist/modules/auth/commands/users.js +14 -1
  4. package/dist/modules/auth/commands/users.js.map +2 -2
  5. package/dist/modules/auth/data/entities.js +2 -2
  6. package/dist/modules/auth/data/entities.js.map +2 -2
  7. package/dist/modules/auth/lib/backendChrome.js +26 -3
  8. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  9. package/dist/modules/auth/lib/profile-sections.js +1 -1
  10. package/dist/modules/auth/lib/profile-sections.js.map +2 -2
  11. package/dist/modules/auth/migrations/Migration20260907120000_auth.js +19 -0
  12. package/dist/modules/auth/migrations/Migration20260907120000_auth.js.map +7 -0
  13. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js +2 -2
  14. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js.map +1 -1
  15. package/dist/modules/customer_accounts/api/admin/demo-accounts.js +118 -0
  16. package/dist/modules/customer_accounts/api/admin/demo-accounts.js.map +7 -0
  17. package/dist/modules/customer_accounts/api/invitations/accept.js +23 -6
  18. package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
  19. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +10 -13
  20. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +2 -2
  21. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js +28 -0
  22. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js.map +7 -0
  23. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +6 -1
  24. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  25. package/dist/modules/customer_accounts/data/entities.js +4 -1
  26. package/dist/modules/customer_accounts/data/entities.js.map +2 -2
  27. package/dist/modules/customer_accounts/lib/exampleAccounts.js +9 -0
  28. package/dist/modules/customer_accounts/lib/exampleAccounts.js.map +7 -0
  29. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js +16 -0
  30. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js.map +7 -0
  31. package/dist/modules/customer_accounts/services/customerInvitationService.js +53 -6
  32. package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
  33. package/dist/modules/customer_accounts/setup.js +2 -6
  34. package/dist/modules/customer_accounts/setup.js.map +2 -2
  35. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +55 -40
  36. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  37. package/dist/modules/customers/commands/people.js +16 -1
  38. package/dist/modules/customers/commands/people.js.map +2 -2
  39. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +18 -36
  40. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  41. package/dist/modules/entities/api/records.js +18 -15
  42. package/dist/modules/entities/api/records.js.map +3 -3
  43. package/dist/modules/entities/lib/scoped-field-defs.js +52 -0
  44. package/dist/modules/entities/lib/scoped-field-defs.js.map +7 -0
  45. package/dist/modules/entities/lib/validation.js +4 -35
  46. package/dist/modules/entities/lib/validation.js.map +2 -2
  47. package/dist/modules/integrations/backend/integrations/[id]/page.js +32 -54
  48. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  49. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +12 -29
  50. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  51. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +2 -2
  52. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +1 -1
  53. package/dist/modules/progress/lib/progressServiceImpl.js +14 -3
  54. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  55. package/dist/modules/sales/backend/sales/documents/[id]/page.js +9 -9
  56. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  57. package/dist/modules/sales/components/documents/LineItemDialog.js +50 -10
  58. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  59. package/dist/modules/sales/components/documents/PaymentsSection.js +8 -3
  60. package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
  61. package/dist/modules/sales/components/documents/ReturnEditDialog.js +2 -7
  62. package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +2 -2
  63. package/dist/modules/sales/components/documents/ReturnsSection.js +2 -7
  64. package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
  65. package/dist/modules/sales/components/documents/ShipmentsSection.js +3 -8
  66. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  67. package/dist/modules/wms/commands/sales-order-assignment.js +12 -0
  68. package/dist/modules/wms/commands/sales-order-assignment.js.map +2 -2
  69. package/dist/modules/wms/data/enrichers.js +25 -0
  70. package/dist/modules/wms/data/enrichers.js.map +2 -2
  71. package/dist/modules/wms/lib/enricherCacheTags.js +9 -0
  72. package/dist/modules/wms/lib/enricherCacheTags.js.map +7 -0
  73. package/dist/modules/wms/lib/invalidateInventoryEnricherCache.js +37 -0
  74. package/dist/modules/wms/lib/invalidateInventoryEnricherCache.js.map +7 -0
  75. package/dist/modules/wms/lib/inventoryEnricherCacheSubscriber.js +14 -0
  76. package/dist/modules/wms/lib/inventoryEnricherCacheSubscriber.js.map +7 -0
  77. package/dist/modules/wms/subscribers/invalidate-enricher-cache-balance.js +12 -0
  78. package/dist/modules/wms/subscribers/invalidate-enricher-cache-balance.js.map +7 -0
  79. package/dist/modules/wms/subscribers/invalidate-enricher-cache-inventory.js +12 -0
  80. package/dist/modules/wms/subscribers/invalidate-enricher-cache-inventory.js.map +7 -0
  81. package/dist/modules/wms/subscribers/invalidate-enricher-cache-movement.js +12 -0
  82. package/dist/modules/wms/subscribers/invalidate-enricher-cache-movement.js.map +7 -0
  83. package/dist/modules/wms/subscribers/invalidate-enricher-cache-profile.js +12 -0
  84. package/dist/modules/wms/subscribers/invalidate-enricher-cache-profile.js.map +7 -0
  85. package/dist/modules/wms/subscribers/invalidate-enricher-cache-reservation.js +12 -0
  86. package/dist/modules/wms/subscribers/invalidate-enricher-cache-reservation.js.map +7 -0
  87. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-line.js +12 -0
  88. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-line.js.map +7 -0
  89. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-order.js +12 -0
  90. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-order.js.map +7 -0
  91. package/dist/modules/wms/subscribers/invalidate-enricher-cache-variant.js +12 -0
  92. package/dist/modules/wms/subscribers/invalidate-enricher-cache-variant.js.map +7 -0
  93. package/dist/modules/wms/subscribers/invalidate-enricher-cache-warehouse.js +12 -0
  94. package/dist/modules/wms/subscribers/invalidate-enricher-cache-warehouse.js.map +7 -0
  95. package/package.json +7 -7
  96. package/src/modules/auth/commands/users.ts +14 -1
  97. package/src/modules/auth/data/entities.ts +2 -2
  98. package/src/modules/auth/lib/backendChrome.tsx +46 -2
  99. package/src/modules/auth/lib/profile-sections.tsx +14 -3
  100. package/src/modules/auth/migrations/.snapshot-open-mercato.json +4 -2
  101. package/src/modules/auth/migrations/Migration20260907120000_auth.ts +21 -0
  102. package/src/modules/communication_channels/backend/profile/communication-channels/page.meta.ts +2 -2
  103. package/src/modules/customer_accounts/AGENTS.md +3 -1
  104. package/src/modules/customer_accounts/api/admin/demo-accounts.ts +149 -0
  105. package/src/modules/customer_accounts/api/invitations/accept.ts +23 -6
  106. package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +43 -41
  107. package/src/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.ts +42 -0
  108. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +10 -3
  109. package/src/modules/customer_accounts/data/entities.ts +5 -1
  110. package/src/modules/customer_accounts/i18n/de.json +4 -2
  111. package/src/modules/customer_accounts/i18n/en.json +4 -2
  112. package/src/modules/customer_accounts/i18n/es.json +4 -2
  113. package/src/modules/customer_accounts/i18n/ko.json +4 -2
  114. package/src/modules/customer_accounts/i18n/pl.json +4 -2
  115. package/src/modules/customer_accounts/lib/exampleAccounts.ts +18 -0
  116. package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +5 -7
  117. package/src/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.ts +23 -0
  118. package/src/modules/customer_accounts/services/customerInvitationService.ts +89 -5
  119. package/src/modules/customer_accounts/setup.ts +2 -6
  120. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +74 -41
  121. package/src/modules/customers/commands/people.ts +15 -0
  122. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +19 -44
  123. package/src/modules/entities/api/records.ts +24 -15
  124. package/src/modules/entities/lib/scoped-field-defs.ts +76 -0
  125. package/src/modules/entities/lib/validation.ts +5 -45
  126. package/src/modules/integrations/backend/integrations/[id]/page.tsx +33 -62
  127. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +13 -37
  128. package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +2 -2
  129. package/src/modules/progress/AGENTS.md +1 -1
  130. package/src/modules/progress/lib/progressServiceImpl.ts +26 -3
  131. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -25
  132. package/src/modules/sales/components/documents/LineItemDialog.tsx +53 -9
  133. package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -8
  134. package/src/modules/sales/components/documents/ReturnEditDialog.tsx +2 -8
  135. package/src/modules/sales/components/documents/ReturnsSection.tsx +8 -7
  136. package/src/modules/sales/components/documents/ShipmentsSection.tsx +9 -8
  137. package/src/modules/sales/i18n/de.json +2 -0
  138. package/src/modules/sales/i18n/en.json +2 -0
  139. package/src/modules/sales/i18n/es.json +2 -0
  140. package/src/modules/sales/i18n/ko.json +2 -0
  141. package/src/modules/sales/i18n/pl.json +2 -0
  142. package/src/modules/wms/commands/sales-order-assignment.ts +20 -0
  143. package/src/modules/wms/data/enrichers.ts +25 -0
  144. package/src/modules/wms/lib/enricherCacheTags.ts +29 -0
  145. package/src/modules/wms/lib/invalidateInventoryEnricherCache.ts +71 -0
  146. package/src/modules/wms/lib/inventoryEnricherCacheSubscriber.ts +30 -0
  147. package/src/modules/wms/subscribers/invalidate-enricher-cache-balance.ts +9 -0
  148. package/src/modules/wms/subscribers/invalidate-enricher-cache-inventory.ts +9 -0
  149. package/src/modules/wms/subscribers/invalidate-enricher-cache-movement.ts +9 -0
  150. package/src/modules/wms/subscribers/invalidate-enricher-cache-profile.ts +9 -0
  151. package/src/modules/wms/subscribers/invalidate-enricher-cache-reservation.ts +9 -0
  152. package/src/modules/wms/subscribers/invalidate-enricher-cache-sales-line.ts +14 -0
  153. package/src/modules/wms/subscribers/invalidate-enricher-cache-sales-order.ts +18 -0
  154. package/src/modules/wms/subscribers/invalidate-enricher-cache-variant.ts +9 -0
  155. package/src/modules/wms/subscribers/invalidate-enricher-cache-warehouse.ts +9 -0
@@ -0,0 +1,149 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { compare as bcryptCompare } from 'bcryptjs'
3
+ import { z } from 'zod'
4
+ import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+ import { CustomerUser, CustomerUserRole } from '@open-mercato/core/modules/customer_accounts/data/entities'
9
+ import { EXAMPLE_PORTAL_ACCOUNTS } from '@open-mercato/core/modules/customer_accounts/lib/exampleAccounts'
10
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
12
+ import type { EntityManager } from '@mikro-orm/postgresql'
13
+
14
+ const FEATURE = 'customer_accounts.view'
15
+
16
+ export const metadata = {
17
+ GET: { requireAuth: true, requireFeatures: [FEATURE] },
18
+ }
19
+
20
+ /**
21
+ * Reports which of the `seedExamples` portal accounts can actually be logged into
22
+ * in the caller's organization. An installation initialized with `--no-examples`,
23
+ * or an organization the examples were never seeded into, gets an empty list so
24
+ * the admin UI can hide the demo-credentials sections entirely (#5669).
25
+ *
26
+ * The surfaces this feeds claim "these credentials work", so mere existence is not
27
+ * enough: the lookup applies the same guards `api/login.ts` enforces (active,
28
+ * email-verified, not soft-deleted) and the advertised password is compared
29
+ * against the stored hash, so an account that was deactivated or whose password
30
+ * was changed since seeding drops out instead of being advertised.
31
+ */
32
+ export async function GET(req: Request) {
33
+ const auth = await getAuthFromRequest(req)
34
+ if (!auth) {
35
+ return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })
36
+ }
37
+
38
+ const container = await createRequestContainer()
39
+ const rbacService = container.resolve('rbacService') as RbacService
40
+ const hasAccess = await rbacService.userHasAllFeatures(auth.sub, [FEATURE], { tenantId: auth.tenantId, organizationId: auth.orgId })
41
+ if (!hasAccess) {
42
+ return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })
43
+ }
44
+
45
+ const em = container.resolve('em') as EntityManager
46
+
47
+ // Emails are stored encrypted, so the seeded addresses are matched through the
48
+ // deterministic lookup hash (both the current and the legacy candidate).
49
+ const accountByHash = new Map<string, (typeof EXAMPLE_PORTAL_ACCOUNTS)[number]>()
50
+ for (const account of EXAMPLE_PORTAL_ACCOUNTS) {
51
+ for (const candidate of lookupHashCandidates(account.email)) {
52
+ accountByHash.set(candidate, account)
53
+ }
54
+ }
55
+
56
+ const seededUsers = await findWithDecryption(
57
+ em,
58
+ CustomerUser,
59
+ {
60
+ emailHash: { $in: Array.from(accountByHash.keys()) },
61
+ tenantId: auth.tenantId,
62
+ organizationId: auth.orgId,
63
+ isActive: true,
64
+ emailVerifiedAt: { $ne: null },
65
+ deletedAt: null,
66
+ } as any,
67
+ undefined,
68
+ { tenantId: auth.tenantId, organizationId: auth.orgId },
69
+ )
70
+
71
+ const loginableUsers: typeof seededUsers = []
72
+ for (const user of seededUsers) {
73
+ const account = accountByHash.get(user.emailHash as string)
74
+ if (!account || !user.passwordHash) continue
75
+ if (await bcryptCompare(account.password, user.passwordHash)) loginableUsers.push(user)
76
+ }
77
+
78
+ if (loginableUsers.length === 0) {
79
+ return NextResponse.json({ ok: true, items: [] })
80
+ }
81
+
82
+ const roleLinks = await findWithDecryption(
83
+ em,
84
+ CustomerUserRole,
85
+ { user: { $in: loginableUsers.map((user) => user.id) } as any, deletedAt: null } as any,
86
+ { populate: ['role'] },
87
+ { tenantId: auth.tenantId, organizationId: auth.orgId },
88
+ )
89
+
90
+ const rolesByUserId = new Map<string, Array<{ id: string; name: string; slug: string }>>()
91
+ for (const link of roleLinks) {
92
+ const role = link.role
93
+ if (!role || role.deletedAt) continue
94
+ const entry = { id: role.id, name: role.name, slug: role.slug }
95
+ const bucket = rolesByUserId.get(link.user.id)
96
+ if (bucket) bucket.push(entry)
97
+ else rolesByUserId.set(link.user.id, [entry])
98
+ }
99
+
100
+ const seenEmails = new Set<string>()
101
+ const items: Array<{ email: string; password: string; roles: Array<{ id: string; name: string; slug: string }> }> = []
102
+ for (const user of loginableUsers) {
103
+ const account = accountByHash.get(user.emailHash as string)
104
+ if (!account || seenEmails.has(account.email)) continue
105
+ seenEmails.add(account.email)
106
+ items.push({
107
+ email: account.email,
108
+ password: account.password,
109
+ roles: rolesByUserId.get(user.id) ?? [],
110
+ })
111
+ }
112
+
113
+ // Keep the response ordered like the seed list so the admin table is stable.
114
+ const orderByEmail = new Map(EXAMPLE_PORTAL_ACCOUNTS.map((account, index) => [account.email, index]))
115
+ items.sort((a, b) => (orderByEmail.get(a.email) ?? 0) - (orderByEmail.get(b.email) ?? 0))
116
+
117
+ return NextResponse.json({ ok: true, items })
118
+ }
119
+
120
+ const demoAccountSchema = z.object({
121
+ email: z.string().email(),
122
+ password: z.string(),
123
+ roles: z.array(z.object({ id: z.string().uuid(), name: z.string(), slug: z.string() })),
124
+ })
125
+
126
+ const errorSchema = z.object({ ok: z.literal(false), error: z.string() })
127
+
128
+ const getMethodDoc: OpenApiMethodDoc = {
129
+ summary: 'List seeded demo portal accounts (admin)',
130
+ description:
131
+ 'Returns the example-data portal accounts that exist in the caller’s organization and can still be logged into with the credentials the seeding hook created them with. Accounts that were deactivated, left unverified, soft-deleted, or had their password changed are omitted, as is every account when example data was not seeded.',
132
+ tags: ['Customer Accounts Admin'],
133
+ responses: [{
134
+ status: 200,
135
+ description: 'Seeded demo accounts present in the current organization',
136
+ schema: z.object({ ok: z.literal(true), items: z.array(demoAccountSchema) }),
137
+ }],
138
+ errors: [
139
+ { status: 401, description: 'Not authenticated', schema: errorSchema },
140
+ { status: 403, description: 'Insufficient permissions', schema: errorSchema },
141
+ ],
142
+ }
143
+
144
+ export const openApi: OpenApiRouteDoc = {
145
+ summary: 'Seeded demo portal accounts (admin)',
146
+ methods: {
147
+ GET: getMethodDoc,
148
+ },
149
+ }
@@ -3,11 +3,15 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { invitationAcceptSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
- import { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
6
+ import {
7
+ CustomerInvitationService,
8
+ isCustomerInvitationEmailConflictError,
9
+ } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
7
10
  import { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'
8
11
  import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
9
12
  import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
10
13
  import { getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
14
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
15
 
12
16
  export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }
13
17
 
@@ -29,11 +33,23 @@ export async function POST(req: Request) {
29
33
  const customerSessionService = container.resolve('customerSessionService') as CustomerSessionService
30
34
  const customerRbacService = container.resolve('customerRbacService') as CustomerRbacService
31
35
 
32
- const result = await customerInvitationService.acceptInvitation(
33
- parsed.data.token,
34
- parsed.data.password,
35
- parsed.data.displayName,
36
- )
36
+ let result: Awaited<ReturnType<typeof customerInvitationService.acceptInvitation>>
37
+ try {
38
+ result = await customerInvitationService.acceptInvitation(
39
+ parsed.data.token,
40
+ parsed.data.password,
41
+ parsed.data.displayName,
42
+ )
43
+ } catch (error) {
44
+ if (isCustomerInvitationEmailConflictError(error)) {
45
+ const { translate } = await resolveTranslations()
46
+ return NextResponse.json({
47
+ ok: false,
48
+ error: translate('customer_accounts.errors.emailAlreadyExists', 'An account with this email address already exists'),
49
+ }, { status: 409 })
50
+ }
51
+ throw error
52
+ }
37
53
  if (!result) {
38
54
  return NextResponse.json({ ok: false, error: 'Invalid or expired invitation' }, { status: 400 })
39
55
  }
@@ -117,6 +133,7 @@ const methodDoc: OpenApiMethodDoc = {
117
133
  ],
118
134
  errors: [
119
135
  { status: 400, description: 'Invalid or expired invitation', schema: errorSchema },
136
+ { status: 409, description: 'An account with this email address already exists', schema: errorSchema },
120
137
  ],
121
138
  }
122
139
 
@@ -6,12 +6,7 @@ import { FormHeader } from '@open-mercato/ui/backend/forms'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { buildPortalRootUrl, buildPortalUrlPattern } from '../../../lib/portalUrl'
9
-
10
- const DEMO_CREDENTIALS = [
11
- { email: 'alice.johnson@example.com', password: 'Password123!', role: 'Portal Admin' },
12
- { email: 'bob.smith@example.com', password: 'Password123!', role: 'Buyer' },
13
- { email: 'carol.white@example.com', password: 'Password123!', role: 'Viewer' },
14
- ] as const
9
+ import { useDemoPortalAccounts } from '../useDemoPortalAccounts'
15
10
 
16
11
  export type CustomerAccountsSettingsPageClientProps = {
17
12
  portalOrigin: string
@@ -20,6 +15,7 @@ export type CustomerAccountsSettingsPageClientProps = {
20
15
 
21
16
  export function CustomerAccountsSettingsPageClient({ portalOrigin, portalOrgSlug = null }: CustomerAccountsSettingsPageClientProps) {
22
17
  const t = useT()
18
+ const { accounts: demoAccounts } = useDemoPortalAccounts()
23
19
 
24
20
  const portalUrl = useMemo(() => buildPortalUrlPattern(portalOrigin), [portalOrigin])
25
21
  const portalRootUrl = useMemo(
@@ -73,43 +69,49 @@ export function CustomerAccountsSettingsPageClient({ portalOrigin, portalOrgSlug
73
69
  </p>
74
70
  </div>
75
71
 
76
- <div className="rounded-lg border p-4 space-y-3">
77
- <h3 className="text-sm font-semibold">
78
- {t('customer_accounts.settings.demo_credentials.title', 'Demo Credentials')}
79
- </h3>
80
- <div className="overflow-x-auto">
81
- <table className="w-full text-sm">
82
- <thead>
83
- <tr className="border-b text-left">
84
- <th className="pb-2 pr-4 font-medium text-muted-foreground">
85
- {t('customer_accounts.settings.demo_credentials.email', 'Email')}
86
- </th>
87
- <th className="pb-2 pr-4 font-medium text-muted-foreground">
88
- {t('customer_accounts.settings.demo_credentials.password', 'Password')}
89
- </th>
90
- <th className="pb-2 font-medium text-muted-foreground">
91
- {t('customer_accounts.settings.demo_credentials.role', 'Role')}
92
- </th>
93
- </tr>
94
- </thead>
95
- <tbody>
96
- {DEMO_CREDENTIALS.map((cred) => (
97
- <tr key={cred.email} className="border-b last:border-0">
98
- <td className="py-2 pr-4 font-mono text-xs">{cred.email}</td>
99
- <td className="py-2 pr-4 font-mono text-xs">{cred.password}</td>
100
- <td className="py-2 text-xs">{cred.role}</td>
72
+ {demoAccounts.length > 0 ? (
73
+ <div className="rounded-lg border p-4 space-y-3">
74
+ <h3 className="text-sm font-semibold">
75
+ {t('customer_accounts.settings.demo_credentials.title', 'Demo Credentials')}
76
+ </h3>
77
+ <div className="overflow-x-auto">
78
+ <table className="w-full text-sm">
79
+ <thead>
80
+ <tr className="border-b text-left">
81
+ <th className="pb-2 pr-4 font-medium text-muted-foreground">
82
+ {t('customer_accounts.settings.demo_credentials.email', 'Email')}
83
+ </th>
84
+ <th className="pb-2 pr-4 font-medium text-muted-foreground">
85
+ {t('customer_accounts.settings.demo_credentials.password', 'Password')}
86
+ </th>
87
+ <th className="pb-2 font-medium text-muted-foreground">
88
+ {t('customer_accounts.settings.demo_credentials.role', 'Role')}
89
+ </th>
101
90
  </tr>
102
- ))}
103
- </tbody>
104
- </table>
91
+ </thead>
92
+ <tbody>
93
+ {demoAccounts.map((account) => (
94
+ <tr key={account.email} className="border-b last:border-0">
95
+ <td className="py-2 pr-4 font-mono text-xs">{account.email}</td>
96
+ <td className="py-2 pr-4 font-mono text-xs">{account.password}</td>
97
+ <td className="py-2 text-xs">
98
+ {account.roles.length > 0
99
+ ? account.roles.map((role) => role.name).join(', ')
100
+ : t('customer_accounts.settings.demo_credentials.no_role', 'No role assigned')}
101
+ </td>
102
+ </tr>
103
+ ))}
104
+ </tbody>
105
+ </table>
106
+ </div>
107
+ <p className="text-xs text-muted-foreground">
108
+ {t(
109
+ 'customer_accounts.settings.demo_credentials.note',
110
+ 'These accounts were created automatically for this organization by the example-data seeding step during setup.',
111
+ )}
112
+ </p>
105
113
  </div>
106
- <p className="text-xs text-muted-foreground">
107
- {t(
108
- 'customer_accounts.settings.demo_credentials.note',
109
- 'These credentials are only available if example data was seeded during setup.',
110
- )}
111
- </p>
112
- </div>
114
+ ) : null}
113
115
 
114
116
  <div className="rounded-lg border p-4 space-y-3">
115
117
  <h3 className="text-sm font-semibold">
@@ -0,0 +1,42 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
+
6
+ export type DemoPortalAccount = {
7
+ email: string
8
+ password: string
9
+ roles: Array<{ id: string; name: string; slug: string }>
10
+ }
11
+
12
+ type DemoAccountsResponse = {
13
+ items?: DemoPortalAccount[]
14
+ }
15
+
16
+ /**
17
+ * Loads the example-data portal accounts that exist in the current organization.
18
+ * Callers render demo credentials only for what comes back, so an installation
19
+ * started with `--no-examples` shows nothing at all (#5669). A failed lookup
20
+ * resolves to an empty list — never to a hardcoded fallback.
21
+ */
22
+ export function useDemoPortalAccounts(): { accounts: DemoPortalAccount[] } {
23
+ const [accounts, setAccounts] = React.useState<DemoPortalAccount[]>([])
24
+
25
+ React.useEffect(() => {
26
+ let cancelled = false
27
+ async function load() {
28
+ try {
29
+ const call = await apiCall<DemoAccountsResponse>('/api/customer_accounts/admin/demo-accounts')
30
+ if (cancelled) return
31
+ const items = call.ok && Array.isArray(call.result?.items) ? call.result!.items : []
32
+ setAccounts(items)
33
+ } catch {
34
+ if (!cancelled) setAccounts([])
35
+ }
36
+ }
37
+ load()
38
+ return () => { cancelled = true }
39
+ }, [])
40
+
41
+ return { accounts }
42
+ }
@@ -22,6 +22,7 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
22
22
  import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
23
23
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
24
24
  import { buildPortalRootUrl, buildPortalUrlPattern } from '../../../lib/portalUrl'
25
+ import { useDemoPortalAccounts } from '../useDemoPortalAccounts'
25
26
 
26
27
  type UserRow = {
27
28
  id: string
@@ -248,6 +249,7 @@ export function PortalUsersPageClient({ portalOrigin, portalOrgSlug = null }: Po
248
249
  const [reloadToken, setReloadToken] = React.useState(0)
249
250
  const [roleOptions, setRoleOptions] = React.useState<Array<{ value: string; label: string; id: string }>>([])
250
251
  const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
252
+ const { accounts: demoAccounts } = useDemoPortalAccounts()
251
253
 
252
254
  const { runMutation, retryLastMutation } = useGuardedMutation<{
253
255
  entityType: string
@@ -506,9 +508,14 @@ export function PortalUsersPageClient({ portalOrigin, portalOrgSlug = null }: Po
506
508
  url: buildPortalUrlPattern(portalOrigin),
507
509
  })}
508
510
  </p>
509
- <p className="mt-0.5 text-xs text-status-info-text">
510
- {t('customer_accounts.admin.portalInfo.credentials', 'Demo credentials: alice.johnson@example.com / Password123!')}
511
- </p>
511
+ {demoAccounts.length > 0 ? (
512
+ <p className="mt-0.5 text-xs text-status-info-text">
513
+ {t('customer_accounts.admin.portalInfo.credentials', 'Seeded demo credentials: {email} / {password}', {
514
+ email: demoAccounts[0].email,
515
+ password: demoAccounts[0].password,
516
+ })}
517
+ </p>
518
+ ) : null}
512
519
  </div>
513
520
  <div className="flex shrink-0 flex-col gap-2">
514
521
  <Button
@@ -13,7 +13,11 @@ export const DOMAIN_STATUSES: readonly DomainStatus[] = [
13
13
  ] as const
14
14
 
15
15
  @Entity({ tableName: 'customer_users' })
16
- @Unique({ properties: ['tenantId', 'emailHash'], name: 'customer_users_tenant_email_hash_uniq' })
16
+ @Index({
17
+ name: 'customer_users_tenant_email_hash_uniq',
18
+ expression:
19
+ 'create unique index "customer_users_tenant_email_hash_uniq" on "customer_users" ("tenant_id", "email_hash") where "deleted_at" is null',
20
+ })
17
21
  export class CustomerUser {
18
22
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
19
23
  id!: string
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Ermöglicht das Bearbeiten von Rollen und das Entfernen von Teammitgliedern",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "Teammitglieder anzeigen",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Ermöglicht das Anzeigen anderer Teammitglieder in der Organisation",
136
- "customer_accounts.admin.portalInfo.credentials": "Demo credentials: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Geseedete Demo-Zugangsdaten: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.",
138
138
  "customer_accounts.admin.portalInfo.open": "Open Portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Open Configuration",
@@ -274,6 +274,7 @@
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.errors.emailAlreadyExists": "Es existiert bereits ein Konto mit dieser E-Mail-Adresse",
277
278
  "customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
278
279
  "customer_accounts.invitation.email.cta": "Accept invitation",
279
280
  "customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Kundenbenutzer",
308
309
  "customer_accounts.settings.back": "Kundenbenutzer",
309
310
  "customer_accounts.settings.demo_credentials.email": "Email",
310
- "customer_accounts.settings.demo_credentials.note": "These credentials are only available if example data was seeded during setup.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "Keine Rolle zugewiesen",
312
+ "customer_accounts.settings.demo_credentials.note": "Diese Konten wurden während der Einrichtung automatisch für diese Organisation durch den Schritt zum Seeden der Beispieldaten erstellt.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Password",
312
314
  "customer_accounts.settings.demo_credentials.role": "Role",
313
315
  "customer_accounts.settings.demo_credentials.title": "Demo Credentials",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Allows editing roles and removing team members",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "View team members",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Allows viewing other team members in the organization",
136
- "customer_accounts.admin.portalInfo.credentials": "Demo credentials: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Seeded demo credentials: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.",
138
138
  "customer_accounts.admin.portalInfo.open": "Open Portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Open Configuration",
@@ -274,6 +274,7 @@
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.errors.emailAlreadyExists": "An account with this email address already exists",
277
278
  "customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
278
279
  "customer_accounts.invitation.email.cta": "Accept invitation",
279
280
  "customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Customer user",
308
309
  "customer_accounts.settings.back": "Customer Users",
309
310
  "customer_accounts.settings.demo_credentials.email": "Email",
310
- "customer_accounts.settings.demo_credentials.note": "These credentials are only available if example data was seeded during setup.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "No role assigned",
312
+ "customer_accounts.settings.demo_credentials.note": "These accounts were created automatically for this organization by the example-data seeding step during setup.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Password",
312
314
  "customer_accounts.settings.demo_credentials.role": "Role",
313
315
  "customer_accounts.settings.demo_credentials.title": "Demo Credentials",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Permite editar roles y eliminar miembros del equipo",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "Ver miembros del equipo",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Permite ver a otros miembros del equipo en la organización",
136
- "customer_accounts.admin.portalInfo.credentials": "Demo credentials: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Credenciales de demostración sembradas: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.",
138
138
  "customer_accounts.admin.portalInfo.open": "Open Portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Open Configuration",
@@ -274,6 +274,7 @@
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.errors.emailAlreadyExists": "Ya existe una cuenta con esta dirección de correo electrónico",
277
278
  "customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
278
279
  "customer_accounts.invitation.email.cta": "Accept invitation",
279
280
  "customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Usuario de cliente",
308
309
  "customer_accounts.settings.back": "Usuarios de clientes",
309
310
  "customer_accounts.settings.demo_credentials.email": "Email",
310
- "customer_accounts.settings.demo_credentials.note": "These credentials are only available if example data was seeded during setup.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "Sin rol asignado",
312
+ "customer_accounts.settings.demo_credentials.note": "Estas cuentas se crearon automáticamente para esta organización mediante el paso de carga de datos de ejemplo durante la configuración.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Password",
312
314
  "customer_accounts.settings.demo_credentials.role": "Role",
313
315
  "customer_accounts.settings.demo_credentials.title": "Demo Credentials",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "역할 편집 및 팀 구성원 제거 허용",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "팀원 보기",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "조직의 다른 팀 구성원을 볼 수 있습니다.",
136
- "customer_accounts.admin.portalInfo.credentials": "데모 자격 증명: alice.johnson@example.com / password123",
136
+ "customer_accounts.admin.portalInfo.credentials": "시드된 데모 자격 증명: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "고객 포털 계정을 관리합니다. 고객은 셀프 등록, 로그인을 할 수 있으며 포털을 통해 주문, 견적, 인보이스에 접근할 수 있습니다.",
138
138
  "customer_accounts.admin.portalInfo.open": "포털 열기",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "구성 열기",
@@ -274,6 +274,7 @@
274
274
  "customer_accounts.domainMapping.tls.diagnostics.title": "SSL 인증서 문제",
275
275
  "customer_accounts.domainMapping.tls.retryButton": "SSL 재시도",
276
276
  "customer_accounts.domainMapping.verifyNow": "지금 확인",
277
+ "customer_accounts.errors.emailAlreadyExists": "이 이메일 주소로 등록된 계정이 이미 있습니다",
277
278
  "customer_accounts.invitation.email.body": "이 보안 초대 링크를 사용하여 비밀번호를 생성하고 고객 포털에 액세스하세요.",
278
279
  "customer_accounts.invitation.email.cta": "초대 수락",
279
280
  "customer_accounts.invitation.email.hint": "이 초대 링크는 72시간 후에 만료됩니다. 이 초대를 예상하지 못했다면 이 이메일을 무시해도 됩니다.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "고객 사용자",
308
309
  "customer_accounts.settings.back": "고객 사용자",
309
310
  "customer_accounts.settings.demo_credentials.email": "이메일",
310
- "customer_accounts.settings.demo_credentials.note": "이 자격 증명은 설정 중 예제 데이터가 시드된 경우에만 사용할 수 있습니다.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "할당된 역할 없음",
312
+ "customer_accounts.settings.demo_credentials.note": "이 계정은 설정 중 예제 데이터 시딩 단계에서 이 조직을 위해 자동으로 생성되었습니다.",
311
313
  "customer_accounts.settings.demo_credentials.password": "비밀번호",
312
314
  "customer_accounts.settings.demo_credentials.role": "역할",
313
315
  "customer_accounts.settings.demo_credentials.title": "데모 자격 증명",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Umożliwia edycję ról i usuwanie członków zespołu",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "Wyświetlanie członków zespołu",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Umożliwia przeglądanie innych członków zespołu w organizacji",
136
- "customer_accounts.admin.portalInfo.credentials": "Dane demonstracyjne: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Zaseedowane dane demonstracyjne: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Zarządzaj kontami portalu klienta. Klienci mogą sami się rejestrować, logować oraz przeglądać zamówienia, oferty i faktury w portalu.",
138
138
  "customer_accounts.admin.portalInfo.open": "Otwórz portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Otwórz konfigurację",
@@ -274,6 +274,7 @@
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.errors.emailAlreadyExists": "Konto z tym adresem e-mail już istnieje",
277
278
  "customer_accounts.invitation.email.body": "Użyj bezpiecznego linku zaproszenia, aby utworzyć hasło i uzyskać dostęp do portalu klienta.",
278
279
  "customer_accounts.invitation.email.cta": "Przyjmij zaproszenie",
279
280
  "customer_accounts.invitation.email.hint": "Ten link zaproszenia wygasa po 72 godzinach. Jeśli nie spodziewasz się tego zaproszenia, możesz zignorować tę wiadomość.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Użytkownik klienta",
308
309
  "customer_accounts.settings.back": "Użytkownicy klientów",
309
310
  "customer_accounts.settings.demo_credentials.email": "E-mail",
310
- "customer_accounts.settings.demo_credentials.note": "Te dane są dostępne tylko wtedy, gdy podczas konfiguracji zaseedowano dane przykładowe.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "Brak przypisanej roli",
312
+ "customer_accounts.settings.demo_credentials.note": "Te konta zostały utworzone automatycznie dla tej organizacji przez krok seedowania danych przykładowych podczas konfiguracji.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Hasło",
312
314
  "customer_accounts.settings.demo_credentials.role": "Rola",
313
315
  "customer_accounts.settings.demo_credentials.title": "Dane demonstracyjne",
@@ -0,0 +1,18 @@
1
+ export type ExamplePortalAccount = {
2
+ email: string
3
+ displayName: string
4
+ password: string
5
+ roleSlug: string
6
+ }
7
+
8
+ /**
9
+ * Portal accounts created by the module's `seedExamples` hook. Setup seeds from
10
+ * this list and the admin demo-credentials endpoint reports against it, so the
11
+ * advertised credentials can never drift from the seeded ones (#3198) and are
12
+ * only surfaced for accounts that actually exist in the current scope (#5669).
13
+ */
14
+ export const EXAMPLE_PORTAL_ACCOUNTS: readonly ExamplePortalAccount[] = [
15
+ { email: 'alice.johnson@example.com', displayName: 'Alice Johnson', password: 'Password123!', roleSlug: 'portal_admin' },
16
+ { email: 'bob.smith@example.com', displayName: 'Bob Smith', password: 'Password123!', roleSlug: 'buyer' },
17
+ { email: 'carol.white@example.com', displayName: 'Carol White', password: 'Password123!', roleSlug: 'viewer' },
18
+ ] as const
@@ -1821,15 +1821,13 @@
1821
1821
  "unique": true
1822
1822
  },
1823
1823
  {
1824
- "columnNames": [
1825
- "tenant_id",
1826
- "email_hash"
1827
- ],
1828
- "composite": true,
1829
- "constraint": true,
1830
1824
  "keyName": "customer_users_tenant_email_hash_uniq",
1825
+ "columnNames": [],
1826
+ "composite": false,
1827
+ "constraint": false,
1831
1828
  "primary": false,
1832
- "unique": true
1829
+ "unique": false,
1830
+ "expression": "create unique index \"customer_users_tenant_email_hash_uniq\" on \"customer_users\" (\"tenant_id\", \"email_hash\") where \"deleted_at\" is null"
1833
1831
  }
1834
1832
  ],
1835
1833
  "checks": [],
@@ -0,0 +1,23 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260827160000_customer_accounts extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table "customer_users" drop constraint "customer_users_tenant_email_hash_uniq";`);
7
+ this.addSql(`create unique index "customer_users_tenant_email_hash_uniq" on "customer_users" ("tenant_id", "email_hash") where "deleted_at" is null;`);
8
+ }
9
+
10
+ override async down(): Promise<void> {
11
+ this.addSql(`drop index if exists "customer_users_tenant_email_hash_uniq";`);
12
+ // Once the partial index has been live, a soft-deleted row legitimately shares
13
+ // (tenant_id, email_hash) with a newer row — which is the point of this change —
14
+ // so the full constraint cannot be restored while both exist. Purge the losing
15
+ // soft-deleted duplicates first: this is destructive, but it only removes rows
16
+ // that were already logically deleted, and without it `down()` fails on exactly
17
+ // the data the migration enables. The surviving row is the active one, else the
18
+ // most recently created.
19
+ this.addSql(`delete from "customer_users" losing using "customer_users" winner where losing."deleted_at" is not null and losing."tenant_id" = winner."tenant_id" and losing."email_hash" = winner."email_hash" and losing."id" <> winner."id" and (winner."deleted_at" is null or winner."created_at" > losing."created_at" or (winner."created_at" = losing."created_at" and winner."id" > losing."id"));`);
20
+ this.addSql(`alter table "customer_users" add constraint "customer_users_tenant_email_hash_uniq" unique ("tenant_id", "email_hash");`);
21
+ }
22
+
23
+ }