@open-mercato/core 0.9.1-develop.7519.1.38f51f8dbb → 0.9.1-develop.7536.1.f9a5776a07

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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/logout.js +14 -6
  3. package/dist/modules/auth/api/logout.js.map +2 -2
  4. package/dist/modules/auth/api/session/refresh.js +6 -1
  5. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  6. package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
  7. package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
  8. package/dist/modules/catalog/ai-tools/types.js.map +2 -2
  9. package/dist/modules/currencies/backend/currencies/[id]/page.js +5 -2
  10. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  11. package/dist/modules/currencies/commands/currencies.js +399 -137
  12. package/dist/modules/currencies/commands/currencies.js.map +2 -2
  13. package/dist/modules/currencies/lib/seeds.js +2 -0
  14. package/dist/modules/currencies/lib/seeds.js.map +2 -2
  15. package/dist/modules/customer_accounts/api/login.js +14 -7
  16. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  17. package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
  18. package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
  19. package/dist/modules/customers/commands/companies.js +10 -3
  20. package/dist/modules/customers/commands/companies.js.map +2 -2
  21. package/dist/modules/customers/commands/interactionRestoreDetails.js +75 -0
  22. package/dist/modules/customers/commands/interactionRestoreDetails.js.map +7 -0
  23. package/dist/modules/customers/commands/people.js +10 -3
  24. package/dist/modules/customers/commands/people.js.map +2 -2
  25. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
  26. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
  27. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
  28. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
  29. package/dist/modules/forms/api/form-submissions/route.js +16 -1
  30. package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
  31. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
  32. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
  33. package/dist/modules/forms/api/runtime-helpers.js +16 -0
  34. package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
  35. package/dist/modules/forms/data/validators.js.map +2 -2
  36. package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
  37. package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
  38. package/dist/modules/forms/lib/runtime-principal.js +27 -10
  39. package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
  40. package/dist/modules/forms/services/distribution-service.js +35 -13
  41. package/dist/modules/forms/services/distribution-service.js.map +2 -2
  42. package/dist/modules/forms/services/submission-service.js +4 -0
  43. package/dist/modules/forms/services/submission-service.js.map +2 -2
  44. package/dist/modules/staff/commands/leave-requests.js +54 -24
  45. package/dist/modules/staff/commands/leave-requests.js.map +2 -2
  46. package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
  47. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  48. package/dist/modules/warranty_claims/commands/claims.js +1 -1
  49. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  50. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
  51. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
  52. package/dist/modules/wms/commands/inventory-actions.js +19 -3
  53. package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
  54. package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
  55. package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
  56. package/dist/modules/workflows/lib/parallel-handler.js +20 -14
  57. package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
  58. package/dist/modules/workflows/lib/workflow-executor.js +21 -23
  59. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  60. package/package.json +7 -7
  61. package/src/modules/auth/api/logout.ts +15 -6
  62. package/src/modules/auth/api/session/refresh.ts +6 -1
  63. package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
  64. package/src/modules/catalog/ai-tools/types.ts +1 -0
  65. package/src/modules/currencies/backend/currencies/[id]/page.tsx +5 -1
  66. package/src/modules/currencies/commands/currencies.ts +475 -168
  67. package/src/modules/currencies/i18n/de.json +4 -0
  68. package/src/modules/currencies/i18n/en.json +4 -0
  69. package/src/modules/currencies/i18n/es.json +4 -0
  70. package/src/modules/currencies/i18n/ko.json +4 -0
  71. package/src/modules/currencies/i18n/pl.json +4 -0
  72. package/src/modules/currencies/lib/seeds.ts +2 -0
  73. package/src/modules/customer_accounts/api/login.ts +15 -7
  74. package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
  75. package/src/modules/customers/commands/companies.ts +16 -4
  76. package/src/modules/customers/commands/interactionRestoreDetails.ts +110 -0
  77. package/src/modules/customers/commands/people.ts +16 -4
  78. package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
  79. package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
  80. package/src/modules/forms/api/form-submissions/route.ts +21 -2
  81. package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
  82. package/src/modules/forms/api/runtime-helpers.ts +16 -0
  83. package/src/modules/forms/data/validators.ts +2 -1
  84. package/src/modules/forms/i18n/de.json +1 -0
  85. package/src/modules/forms/i18n/en.json +1 -0
  86. package/src/modules/forms/i18n/es.json +1 -0
  87. package/src/modules/forms/i18n/ko.json +1 -0
  88. package/src/modules/forms/i18n/pl.json +1 -0
  89. package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
  90. package/src/modules/forms/lib/runtime-principal.ts +35 -11
  91. package/src/modules/forms/services/distribution-service.ts +35 -13
  92. package/src/modules/forms/services/submission-service.ts +12 -0
  93. package/src/modules/staff/commands/leave-requests.ts +54 -23
  94. package/src/modules/staff/i18n/de.json +2 -0
  95. package/src/modules/staff/i18n/en.json +2 -0
  96. package/src/modules/staff/i18n/es.json +2 -0
  97. package/src/modules/staff/i18n/ko.json +2 -0
  98. package/src/modules/staff/i18n/pl.json +2 -0
  99. package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
  100. package/src/modules/warranty_claims/commands/claims.ts +1 -1
  101. package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
  102. package/src/modules/wms/commands/inventory-actions.ts +30 -10
  103. package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
  104. package/src/modules/workflows/lib/parallel-handler.ts +24 -15
  105. package/src/modules/workflows/lib/workflow-executor.ts +24 -27
@@ -6,6 +6,9 @@
6
6
  "currencies.create.title": "Währung erstellen",
7
7
  "currencies.currencies.update": "Währung aktualisieren",
8
8
  "currencies.edit.title": "Währung bearbeiten",
9
+ "currencies.errors.baseCurrencyUnset": "Wählen Sie stattdessen eine andere Währung als Basiswährung, anstatt sie zu entfernen.",
10
+ "currencies.errors.undoHasRates": "Diese Änderung kann nicht rückgängig gemacht werden, da inzwischen Wechselkurse diese Währung verwenden. Löschen oder deaktivieren Sie diese Wechselkurse zuerst.",
11
+ "currencies.errors.undoWouldClearBase": "Diese Änderung kann nicht rückgängig gemacht werden, da die Organisation sonst keine Basiswährung hätte.",
9
12
  "currencies.exchange_rates.update": "Wechselkurs aktualisieren",
10
13
  "currencies.fetch.description": "Verwalten Sie den automatischen Abruf von Währungskursen von externen Anbietern",
11
14
  "currencies.fetch.disabled": "Deaktiviert",
@@ -54,6 +57,7 @@
54
57
  "currencies.form.field.decimalSeparator": "Dezimaltrennzeichen",
55
58
  "currencies.form.field.isActive": "Aktiv",
56
59
  "currencies.form.field.isBase": "Basiswährung",
60
+ "currencies.form.field.isBaseLocked": "Dies ist die Basiswährung. Um sie zu ändern, legen Sie eine andere Währung als Basis fest.",
57
61
  "currencies.form.field.name": "Währungsname",
58
62
  "currencies.form.field.namePlaceholder": "US-Dollar",
59
63
  "currencies.form.field.symbol": "Symbol",
@@ -6,6 +6,9 @@
6
6
  "currencies.create.title": "Create Currency",
7
7
  "currencies.currencies.update": "Update currency",
8
8
  "currencies.edit.title": "Edit Currency",
9
+ "currencies.errors.baseCurrencyUnset": "Choose another currency as the base instead of clearing it.",
10
+ "currencies.errors.undoHasRates": "This change can't be undone because exchange rates now use this currency. Delete or deactivate those exchange rates first.",
11
+ "currencies.errors.undoWouldClearBase": "This change can't be undone because it would leave the organization without a base currency.",
9
12
  "currencies.exchange_rates.update": "Update exchange rate",
10
13
  "currencies.fetch.description": "Manage automatic currency exchange rate fetching from external providers",
11
14
  "currencies.fetch.disabled": "Disabled",
@@ -54,6 +57,7 @@
54
57
  "currencies.form.field.decimalSeparator": "Decimal Separator",
55
58
  "currencies.form.field.isActive": "Active",
56
59
  "currencies.form.field.isBase": "Base Currency",
60
+ "currencies.form.field.isBaseLocked": "This is the base currency. To change it, set another currency as the base.",
57
61
  "currencies.form.field.name": "Currency Name",
58
62
  "currencies.form.field.namePlaceholder": "US Dollar",
59
63
  "currencies.form.field.symbol": "Symbol",
@@ -6,6 +6,9 @@
6
6
  "currencies.create.title": "Crear Moneda",
7
7
  "currencies.currencies.update": "Actualizar moneda",
8
8
  "currencies.edit.title": "Editar Moneda",
9
+ "currencies.errors.baseCurrencyUnset": "Elija otra moneda como base en lugar de quitarla.",
10
+ "currencies.errors.undoHasRates": "Este cambio no se puede deshacer porque ahora hay tipos de cambio que usan esta moneda. Elimine o desactive primero esos tipos de cambio.",
11
+ "currencies.errors.undoWouldClearBase": "Este cambio no se puede deshacer porque dejaría a la organización sin moneda base.",
9
12
  "currencies.exchange_rates.update": "Actualizar tipo de cambio",
10
13
  "currencies.fetch.description": "Gestionar la obtención automática de tipos de cambio desde proveedores externos",
11
14
  "currencies.fetch.disabled": "Deshabilitado",
@@ -54,6 +57,7 @@
54
57
  "currencies.form.field.decimalSeparator": "Separador Decimal",
55
58
  "currencies.form.field.isActive": "Activa",
56
59
  "currencies.form.field.isBase": "Moneda Base",
60
+ "currencies.form.field.isBaseLocked": "Esta es la moneda base. Para cambiarla, establezca otra moneda como base.",
57
61
  "currencies.form.field.name": "Nombre de Moneda",
58
62
  "currencies.form.field.namePlaceholder": "Dólar estadounidense",
59
63
  "currencies.form.field.symbol": "Símbolo",
@@ -6,6 +6,9 @@
6
6
  "currencies.create.title": "통화 생성",
7
7
  "currencies.currencies.update": "통화 수정",
8
8
  "currencies.edit.title": "통화 편집",
9
+ "currencies.errors.baseCurrencyUnset": "기준 통화를 해제하는 대신 다른 통화를 기준 통화로 선택하세요.",
10
+ "currencies.errors.undoHasRates": "이제 이 통화를 사용하는 환율이 있어 이 변경을 취소할 수 없습니다. 먼저 해당 환율을 삭제하거나 비활성화하세요.",
11
+ "currencies.errors.undoWouldClearBase": "이 변경을 취소하면 조직에 기준 통화가 없게 되므로 취소할 수 없습니다.",
9
12
  "currencies.exchange_rates.update": "환율 수정",
10
13
  "currencies.fetch.description": "외부 제공업체로부터 자동 환율 가져오기를 관리합니다",
11
14
  "currencies.fetch.disabled": "비활성화됨",
@@ -54,6 +57,7 @@
54
57
  "currencies.form.field.decimalSeparator": "소수 구분 기호",
55
58
  "currencies.form.field.isActive": "활성",
56
59
  "currencies.form.field.isBase": "기준 통화",
60
+ "currencies.form.field.isBaseLocked": "기준 통화입니다. 변경하려면 다른 통화를 기준 통화로 설정하세요.",
57
61
  "currencies.form.field.name": "통화 이름",
58
62
  "currencies.form.field.namePlaceholder": "미국 달러",
59
63
  "currencies.form.field.symbol": "기호",
@@ -6,6 +6,9 @@
6
6
  "currencies.create.title": "Utwórz Walutę",
7
7
  "currencies.currencies.update": "Aktualizuj walutę",
8
8
  "currencies.edit.title": "Edytuj Walutę",
9
+ "currencies.errors.baseCurrencyUnset": "Zamiast odznaczać walutę bazową, wybierz inną walutę jako bazową.",
10
+ "currencies.errors.undoHasRates": "Tej zmiany nie można cofnąć, ponieważ kursy wymiany korzystają teraz z tej waluty. Najpierw usuń lub dezaktywuj te kursy wymiany.",
11
+ "currencies.errors.undoWouldClearBase": "Tej zmiany nie można cofnąć, ponieważ organizacja zostałaby bez waluty bazowej.",
9
12
  "currencies.exchange_rates.update": "Aktualizuj kurs wymiany",
10
13
  "currencies.fetch.description": "Zarządzaj automatycznym pobieraniem kursów walut od zewnętrznych dostawców",
11
14
  "currencies.fetch.disabled": "Wyłączony",
@@ -54,6 +57,7 @@
54
57
  "currencies.form.field.decimalSeparator": "Separator Dziesiętny",
55
58
  "currencies.form.field.isActive": "Aktywna",
56
59
  "currencies.form.field.isBase": "Waluta Bazowa",
60
+ "currencies.form.field.isBaseLocked": "To jest waluta bazowa. Aby ją zmienić, ustaw inną walutę jako bazową.",
57
61
  "currencies.form.field.name": "Nazwa Waluty",
58
62
  "currencies.form.field.namePlaceholder": "Dolar amerykański",
59
63
  "currencies.form.field.symbol": "Symbol",
@@ -126,6 +126,7 @@ export async function seedExampleCurrencies(em: EntityManager, scope: CurrencySe
126
126
  for (const entry of existingEntries) {
127
127
  existingMap.set(entry.code, entry)
128
128
  }
129
+ const hasLiveBase = existingEntries.some((entry) => entry.isBase && !entry.deletedAt)
129
130
 
130
131
  let touched = false
131
132
  for (const curr of SEED_CURRENCIES) {
@@ -143,6 +144,7 @@ export async function seedExampleCurrencies(em: EntityManager, scope: CurrencySe
143
144
  tenantId: scope.tenantId,
144
145
  organizationId: scope.organizationId,
145
146
  ...curr,
147
+ isBase: curr.isBase && !hasLiveBase,
146
148
  createdAt: new Date(),
147
149
  updatedAt: new Date(),
148
150
  })
@@ -8,6 +8,7 @@ import { CustomerUserService } from '@open-mercato/core/modules/customer_account
8
8
  import { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'
9
9
  import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
10
10
  import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
11
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
11
12
  import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
12
13
  import { getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
13
14
  import {
@@ -28,6 +29,10 @@ export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth:
28
29
  // so account existence cannot be inferred from a timing side channel. Mirrors signup.ts.
29
30
  const TIMING_EQUALIZATION_HASH = '$2b$10$.F2A6UHFzk.d8trNdfqt4OLz05Nf3IOuMmN6VJKflhD4.rz.prR8i'
30
31
 
32
+ function reportLoginEventError(error: unknown): void {
33
+ getTelemetryRuntime()?.reportError(error, { module: 'customer_accounts', code: 'customer_accounts.login_event_failed' })
34
+ }
35
+
31
36
  export async function POST(req: Request) {
32
37
  let body: unknown
33
38
  try {
@@ -77,26 +82,26 @@ export async function POST(req: Request) {
77
82
  // conveyed out-of-band (e.g. email), never in the synchronous response.
78
83
  if (!user || !user.passwordHash) {
79
84
  await bcryptCompare(password, TIMING_EQUALIZATION_HASH)
80
- void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_credentials', tenantId }).catch(() => undefined)
85
+ void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_credentials', tenantId }).catch(reportLoginEventError)
81
86
  return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
82
87
  }
83
88
 
84
89
  if (!user.isActive) {
85
90
  await bcryptCompare(password, TIMING_EQUALIZATION_HASH)
86
- void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'inactive', tenantId }).catch(() => undefined)
91
+ void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'inactive', tenantId }).catch(reportLoginEventError)
87
92
  return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
88
93
  }
89
94
 
90
95
  if (customerUserService.checkLockout(user)) {
91
96
  await bcryptCompare(password, TIMING_EQUALIZATION_HASH)
92
- void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'locked', tenantId }).catch(() => undefined)
97
+ void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'locked', tenantId }).catch(reportLoginEventError)
93
98
  return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
94
99
  }
95
100
 
96
101
  const passwordValid = await customerUserService.verifyPassword(user, password)
97
102
  if (!passwordValid) {
98
103
  await customerUserService.incrementFailedAttempts(user)
99
- void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_password', tenantId }).catch(() => undefined)
104
+ void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_password', tenantId }).catch(reportLoginEventError)
100
105
  return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
101
106
  }
102
107
 
@@ -105,11 +110,14 @@ export async function POST(req: Request) {
105
110
  email,
106
111
  reason: 'email_not_verified',
107
112
  tenantId,
108
- }).catch(() => undefined)
113
+ }).catch(reportLoginEventError)
109
114
  return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
110
115
  }
111
116
 
112
- await customerUserService.resetFailedAttempts(user)
117
+ if (!(await customerUserService.resetFailedAttemptsUnlessLocked(user))) {
118
+ void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'locked', tenantId }).catch(reportLoginEventError)
119
+ return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
120
+ }
113
121
  await customerUserService.updateLastLoginAt(user)
114
122
 
115
123
  if (compoundKey) {
@@ -130,7 +138,7 @@ export async function POST(req: Request) {
130
138
  email: user.email,
131
139
  tenantId,
132
140
  organizationId: user.organizationId,
133
- }).catch(() => undefined)
141
+ }).catch(reportLoginEventError)
134
142
 
135
143
  const res = NextResponse.json({
136
144
  ok: true,
@@ -8,6 +8,14 @@ const BCRYPT_COST = 10
8
8
  const MAX_FAILED_ATTEMPTS = 5
9
9
  const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
10
10
 
11
+ type CustomerUserLockoutDatabase = {
12
+ customer_users: {
13
+ id: string
14
+ failed_login_attempts: number
15
+ locked_until: Date | null
16
+ }
17
+ }
18
+
11
19
  export class CustomerUserService {
12
20
  constructor(private em: EntityManager) {}
13
21
 
@@ -81,14 +89,32 @@ export class CustomerUserService {
81
89
  }
82
90
 
83
91
  async incrementFailedAttempts(user: CustomerUser): Promise<void> {
84
- const newCount = (user.failedLoginAttempts || 0) + 1
85
- const updates: Record<string, unknown> = { failedLoginAttempts: newCount }
86
- if (newCount >= MAX_FAILED_ATTEMPTS) {
87
- updates.lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS)
88
- }
89
- await this.em.nativeUpdate(CustomerUser, { id: user.id }, updates)
90
- user.failedLoginAttempts = newCount
91
- if (updates.lockedUntil) user.lockedUntil = updates.lockedUntil as Date
92
+ const lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS)
93
+ await this.em.getKysely<CustomerUserLockoutDatabase>()
94
+ .updateTable('customer_users')
95
+ .set((eb) => ({
96
+ failed_login_attempts: eb('failed_login_attempts', '+', 1),
97
+ locked_until: eb
98
+ .case()
99
+ .when('failed_login_attempts', '>=', MAX_FAILED_ATTEMPTS - 1)
100
+ .then(eb.fn<Date>('greatest', [eb.ref('locked_until'), eb.val(lockedUntil)]))
101
+ .else(eb.ref('locked_until'))
102
+ .end(),
103
+ }))
104
+ .where('id', '=', user.id)
105
+ .execute()
106
+ }
107
+
108
+ async resetFailedAttemptsUnlessLocked(user: CustomerUser): Promise<boolean> {
109
+ const now = new Date()
110
+ const row = await this.em.getKysely<CustomerUserLockoutDatabase>()
111
+ .updateTable('customer_users')
112
+ .set({ failed_login_attempts: 0, locked_until: null })
113
+ .where('id', '=', user.id)
114
+ .where((eb) => eb.or([eb('locked_until', 'is', null), eb('locked_until', '<=', now)]))
115
+ .returning('id')
116
+ .executeTakeFirst()
117
+ return row !== undefined
92
118
  }
93
119
 
94
120
  async resetFailedAttempts(user: CustomerUser): Promise<void> {
@@ -96,8 +122,6 @@ export class CustomerUserService {
96
122
  failedLoginAttempts: 0,
97
123
  lockedUntil: null,
98
124
  })
99
- user.failedLoginAttempts = 0
100
- user.lockedUntil = null
101
125
  }
102
126
 
103
127
  async updatePassword(user: CustomerUser, newPassword: string, em?: EntityManager): Promise<void> {
@@ -26,6 +26,12 @@ import {
26
26
  CustomerPersonProfile,
27
27
  CustomerTagAssignment,
28
28
  } from '../data/entities'
29
+ import {
30
+ captureInteractionRestoreDetails,
31
+ omitInteractionRestoreDetails,
32
+ restoreInteractionRestoreDetails,
33
+ type InteractionRestoreDetails,
34
+ } from './interactionRestoreDetails'
29
35
  import {
30
36
  companyCreateSchema,
31
37
  companyUpdateSchema,
@@ -189,7 +195,7 @@ type CompanyInteractionSnapshot = {
189
195
  updatedAt: Date
190
196
  deletedAt: Date | null
191
197
  custom?: Record<string, unknown>
192
- }
198
+ } & InteractionRestoreDetails
193
199
 
194
200
  type CompanySnapshot = {
195
201
  entity: {
@@ -246,7 +252,11 @@ type CompanyUndoPayload = {
246
252
  after?: CompanySnapshot | null
247
253
  }
248
254
 
249
- async function loadCompanySnapshot(em: EntityManager, id: string): Promise<CompanySnapshot | null> {
255
+ async function loadCompanySnapshot(
256
+ em: EntityManager,
257
+ id: string,
258
+ options: { includeInteractionRestoreDetails?: boolean } = {},
259
+ ): Promise<CompanySnapshot | null> {
250
260
  const entity = await em.findOne(CustomerEntity, { id, deletedAt: null })
251
261
  if (!entity || entity.kind !== 'company') return null
252
262
  const profile = await em.findOne(CustomerCompanyProfile, { entity })
@@ -408,6 +418,7 @@ async function loadCompanySnapshot(em: EntityManager, id: string): Promise<Compa
408
418
  createdAt: interaction.createdAt,
409
419
  updatedAt: interaction.updatedAt,
410
420
  deletedAt: interaction.deletedAt ?? null,
421
+ ...(options.includeInteractionRestoreDetails ? captureInteractionRestoreDetails(interaction) : {}),
411
422
  custom: await loadCustomFieldSnapshot(em, {
412
423
  entityId: INTERACTION_ENTITY_ID,
413
424
  recordId: interaction.id,
@@ -998,7 +1009,7 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
998
1009
  async prepare(input, ctx) {
999
1010
  const id = requireId(input, 'Company id required')
1000
1011
  const em = (ctx.container.resolve('em') as EntityManager)
1001
- const snapshot = await loadCompanySnapshot(em, id)
1012
+ const snapshot = await loadCompanySnapshot(em, id, { includeInteractionRestoreDetails: true })
1002
1013
  return snapshot ? { before: snapshot } : {}
1003
1014
  },
1004
1015
  async execute(input, ctx) {
@@ -1182,7 +1193,7 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
1182
1193
  resourceId: before.entity.id,
1183
1194
  tenantId: before.entity.tenantId,
1184
1195
  organizationId: before.entity.organizationId,
1185
- snapshotBefore: before,
1196
+ snapshotBefore: omitInteractionRestoreDetails(before),
1186
1197
  payload: {
1187
1198
  undo: {
1188
1199
  before,
@@ -1421,6 +1432,7 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
1421
1432
  source: interaction.source,
1422
1433
  appearanceIcon: interaction.appearanceIcon,
1423
1434
  appearanceColor: interaction.appearanceColor,
1435
+ ...restoreInteractionRestoreDetails(interaction),
1424
1436
  createdAt: interaction.createdAt,
1425
1437
  updatedAt: interaction.updatedAt,
1426
1438
  deletedAt: interaction.deletedAt,
@@ -0,0 +1,110 @@
1
+ import { reviveSnapshotDates } from '@open-mercato/shared/lib/commands/undo'
2
+ import type { CustomerInteraction } from '../data/entities'
3
+
4
+ /**
5
+ * Interaction columns a Person/Company delete snapshot must carry so that undo can recreate each interaction
6
+ * exactly as it was. Every key is optional because delete logs written before these columns were captured
7
+ * do not contain them.
8
+ */
9
+ export type InteractionRestoreDetails = {
10
+ externalMessageId?: string | null
11
+ channelProviderKey?: string | null
12
+ channelId?: string | null
13
+ durationMinutes?: number | null
14
+ location?: string | null
15
+ allDay?: boolean | null
16
+ recurrenceRule?: string | null
17
+ recurrenceEnd?: Date | string | null
18
+ participants?: CustomerInteraction['participants']
19
+ reminderMinutes?: number | null
20
+ visibility?: string | null
21
+ linkedEntities?: CustomerInteraction['linkedEntities']
22
+ guestPermissions?: CustomerInteraction['guestPermissions']
23
+ pinned?: boolean
24
+ }
25
+
26
+ const INTERACTION_RESTORE_DETAIL_KEYS = [
27
+ 'externalMessageId',
28
+ 'channelProviderKey',
29
+ 'channelId',
30
+ 'durationMinutes',
31
+ 'location',
32
+ 'allDay',
33
+ 'recurrenceRule',
34
+ 'recurrenceEnd',
35
+ 'participants',
36
+ 'reminderMinutes',
37
+ 'visibility',
38
+ 'linkedEntities',
39
+ 'guestPermissions',
40
+ 'pinned',
41
+ ] as const satisfies ReadonlyArray<keyof InteractionRestoreDetails>
42
+
43
+ export function captureInteractionRestoreDetails(interaction: CustomerInteraction): Required<InteractionRestoreDetails> {
44
+ return {
45
+ externalMessageId: interaction.externalMessageId ?? null,
46
+ channelProviderKey: interaction.channelProviderKey ?? null,
47
+ channelId: interaction.channelId ?? null,
48
+ durationMinutes: interaction.durationMinutes ?? null,
49
+ location: interaction.location ?? null,
50
+ allDay: interaction.allDay ?? null,
51
+ recurrenceRule: interaction.recurrenceRule ?? null,
52
+ recurrenceEnd: interaction.recurrenceEnd ?? null,
53
+ participants: interaction.participants ?? null,
54
+ reminderMinutes: interaction.reminderMinutes ?? null,
55
+ visibility: interaction.visibility ?? null,
56
+ linkedEntities: interaction.linkedEntities ?? null,
57
+ guestPermissions: interaction.guestPermissions ?? null,
58
+ pinned: interaction.pinned === true,
59
+ }
60
+ }
61
+
62
+ /**
63
+ * A delete log written before visibility was captured cannot tell whether an email was private. An email that
64
+ * has an author is restored private: its author still reads it and can share it again. An email without an
65
+ * author keeps the previous behaviour, because a private row without an author is readable and changeable by
66
+ * nobody.
67
+ */
68
+ function resolveRestoredVisibility(snapshot: { interactionType: string; authorUserId: string | null } & InteractionRestoreDetails): string | null {
69
+ if (Object.prototype.hasOwnProperty.call(snapshot, 'visibility')) return snapshot.visibility ?? null
70
+ return snapshot.interactionType === 'email' && snapshot.authorUserId ? 'private' : null
71
+ }
72
+
73
+ export function restoreInteractionRestoreDetails(
74
+ snapshot: { interactionType: string; authorUserId: string | null } & InteractionRestoreDetails,
75
+ ) {
76
+ const { recurrenceEnd } = reviveSnapshotDates({ recurrenceEnd: snapshot.recurrenceEnd ?? null }, ['recurrenceEnd'])
77
+ return {
78
+ externalMessageId: snapshot.externalMessageId ?? null,
79
+ channelProviderKey: snapshot.channelProviderKey ?? null,
80
+ channelId: snapshot.channelId ?? null,
81
+ durationMinutes: snapshot.durationMinutes ?? null,
82
+ location: snapshot.location ?? null,
83
+ allDay: snapshot.allDay ?? null,
84
+ recurrenceRule: snapshot.recurrenceRule ?? null,
85
+ recurrenceEnd: recurrenceEnd as Date | null,
86
+ participants: snapshot.participants ?? null,
87
+ reminderMinutes: snapshot.reminderMinutes ?? null,
88
+ visibility: resolveRestoredVisibility(snapshot),
89
+ linkedEntities: snapshot.linkedEntities ?? null,
90
+ guestPermissions: snapshot.guestPermissions ?? null,
91
+ pinned: snapshot.pinned === true,
92
+ }
93
+ }
94
+
95
+ /**
96
+ * The delete log's `snapshotBefore` is what the audit log shows; the restore details stay in the undo payload only.
97
+ */
98
+ export function omitInteractionRestoreDetails<TSnapshot extends { interactions?: ReadonlyArray<InteractionRestoreDetails> }>(
99
+ snapshot: TSnapshot,
100
+ ): TSnapshot {
101
+ if (!Array.isArray(snapshot.interactions)) return snapshot
102
+ return {
103
+ ...snapshot,
104
+ interactions: snapshot.interactions.map((interaction) => {
105
+ const visible: Record<string, unknown> = { ...interaction }
106
+ for (const key of INTERACTION_RESTORE_DETAIL_KEYS) delete visible[key]
107
+ return visible
108
+ }),
109
+ }
110
+ }
@@ -24,6 +24,12 @@ import {
24
24
  CustomerPersonProfile,
25
25
  CustomerTagAssignment,
26
26
  } from '../data/entities'
27
+ import {
28
+ captureInteractionRestoreDetails,
29
+ omitInteractionRestoreDetails,
30
+ restoreInteractionRestoreDetails,
31
+ type InteractionRestoreDetails,
32
+ } from './interactionRestoreDetails'
27
33
  import { resolvePersonCustomFieldRouting, CUSTOMER_ENTITY_ID, PERSON_ENTITY_ID } from '../lib/customFieldRouting'
28
34
  import { CustomFieldValue } from '@open-mercato/core/modules/entities/data/entities'
29
35
  import {
@@ -133,7 +139,7 @@ type PersonInteractionSnapshot = {
133
139
  updatedAt: Date
134
140
  deletedAt: Date | null
135
141
  custom?: Record<string, unknown>
136
- }
142
+ } & InteractionRestoreDetails
137
143
 
138
144
  type PersonSnapshot = {
139
145
  entity: {
@@ -380,7 +386,11 @@ function serializePersonSnapshot(
380
386
  }
381
387
  }
382
388
 
383
- async function loadPersonSnapshot(em: EntityManager, entityId: string): Promise<PersonSnapshot | null> {
389
+ async function loadPersonSnapshot(
390
+ em: EntityManager,
391
+ entityId: string,
392
+ options: { includeInteractionRestoreDetails?: boolean } = {},
393
+ ): Promise<PersonSnapshot | null> {
384
394
  const entity = await em.findOne(CustomerEntity, { id: entityId, deletedAt: null })
385
395
  if (!entity || entity.kind !== 'person') return null
386
396
  const profile = await findOneWithDecryption(
@@ -455,6 +465,7 @@ async function loadPersonSnapshot(em: EntityManager, entityId: string): Promise<
455
465
  createdAt: interaction.createdAt,
456
466
  updatedAt: interaction.updatedAt,
457
467
  deletedAt: interaction.deletedAt ?? null,
468
+ ...(options.includeInteractionRestoreDetails ? captureInteractionRestoreDetails(interaction) : {}),
458
469
  custom: await loadCustomFieldSnapshot(em, {
459
470
  entityId: INTERACTION_ENTITY_ID,
460
471
  recordId: interaction.id,
@@ -1231,7 +1242,7 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
1231
1242
  async prepare(input, ctx) {
1232
1243
  const id = requireId(input, 'Person id required')
1233
1244
  const em = (ctx.container.resolve('em') as EntityManager).fork()
1234
- const snapshot = await loadPersonSnapshot(em, id)
1245
+ const snapshot = await loadPersonSnapshot(em, id, { includeInteractionRestoreDetails: true })
1235
1246
  return snapshot ? { before: snapshot } : {}
1236
1247
  },
1237
1248
  async execute(input, ctx) {
@@ -1355,7 +1366,7 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
1355
1366
  resourceId: before.entity.id,
1356
1367
  tenantId: before.entity.tenantId,
1357
1368
  organizationId: before.entity.organizationId,
1358
- snapshotBefore: before,
1369
+ snapshotBefore: omitInteractionRestoreDetails(before),
1359
1370
  payload: {
1360
1371
  undo: {
1361
1372
  before,
@@ -1646,6 +1657,7 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
1646
1657
  source: interaction.source,
1647
1658
  appearanceIcon: interaction.appearanceIcon,
1648
1659
  appearanceColor: interaction.appearanceColor,
1660
+ ...restoreInteractionRestoreDetails(interaction),
1649
1661
  createdAt: interaction.createdAt,
1650
1662
  updatedAt: interaction.updatedAt,
1651
1663
  deletedAt: interaction.deletedAt,
@@ -84,7 +84,7 @@ export const openApi: OpenApiRouteDoc = {
84
84
  DELETE: {
85
85
  summary: 'Revoke invitation',
86
86
  description:
87
- 'Soft-revokes an invitation — the row is retained and its status becomes revoked so a revoked token resolves to 410 (not 404).',
87
+ 'Soft-revokes an invitation — the row is retained and its status becomes revoked so a revoked token resolves to 410 (not 404); access tokens already issued for the invitation stop working (401).',
88
88
  responses: [{ status: 200, description: 'Invitation revoked', schema: deleteResponseSchema }],
89
89
  errors: [{ status: 404, description: 'Invitation not found', schema: errorSchema }],
90
90
  },
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Final submit. Body: `{ base_revision_id, submit_metadata? }`. Sets
5
5
  * status = submitted, captures `submit_metadata` (locale, IP, UA), emits
6
- * `forms.submission.submitted`. Idempotency hardening lands in 1d once the
7
- * client renderer is in place; phase 1c rejects double-submits with 422.
6
+ * `forms.submission.submitted`. A submission the caller started themselves
7
+ * must carry their own subject (`lib/portal-subject-policy.ts`), so a draft
8
+ * started for someone else before that check existed cannot be finalized.
9
+ * Idempotency hardening lands in 1d once the client renderer is in place;
10
+ * phase 1c rejects double-submits with 422.
8
11
  */
9
12
 
10
13
  import { NextResponse, type NextRequest } from 'next/server'
@@ -14,7 +17,14 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
14
17
  import { getCustomerAuthFromRequest } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
15
18
  import { SubmissionService } from '../../../../services/submission-service'
16
19
  import { submissionSubmitInputSchema } from '../../../../data/validators'
17
- import { buildSubmitMetadata, mapSubmissionError, readJsonBody, serializeSubmission } from '../../../runtime-helpers'
20
+ import {
21
+ buildSubmitMetadata,
22
+ isPortalSubjectForbiddenError,
23
+ mapSubmissionError,
24
+ portalSubjectForbiddenResponse,
25
+ readJsonBody,
26
+ serializeSubmission,
27
+ } from '../../../runtime-helpers'
18
28
 
19
29
  export const metadata = {
20
30
  POST: { requireAuth: false },
@@ -56,9 +66,11 @@ export async function POST(
56
66
  baseRevisionId: parsed.data.base_revision_id,
57
67
  submittedBy: auth.sub,
58
68
  submitMetadata: buildSubmitMetadata(req, parsed.data.submit_metadata),
69
+ submittedByPortalCustomer: true,
59
70
  })
60
71
  return NextResponse.json({ submission: serializeSubmission(submission) })
61
72
  } catch (error) {
73
+ if (isPortalSubjectForbiddenError(error)) return portalSubjectForbiddenResponse()
62
74
  return mapSubmissionError(error)
63
75
  }
64
76
  }
@@ -79,6 +91,7 @@ const postMethodDoc: OpenApiMethodDoc = {
79
91
  responses: [{ status: 200, description: 'Submission submitted', schema: responseSchema }],
80
92
  errors: [
81
93
  { status: 401, description: 'Not authenticated', schema: errorSchema },
94
+ { status: 403, description: 'SUBJECT_FORBIDDEN: caller-started submission is not on the caller\'s own customer subject', schema: errorSchema },
82
95
  { status: 404, description: 'Submission not found', schema: errorSchema },
83
96
  { status: 409, description: 'Stale base_revision_id', schema: errorSchema },
84
97
  { status: 422, description: 'Submission already in terminal state', schema: errorSchema },
@@ -2,7 +2,9 @@
2
2
  * Runtime API — POST /api/form-submissions
3
3
  *
4
4
  * Starts a new submission for the active version of a form. Customer auth is
5
- * required. The starter is auto-assigned the form version's
5
+ * required, and the subject must be the caller's own `customer` subject
6
+ * (`lib/portal-subject-policy.ts`); any other pair is refused with 403 before
7
+ * anything is persisted. The starter is auto-assigned the form version's
6
8
  * `x-om-default-actor-role` (or first declared role) — admin assignment of
7
9
  * additional actors is the admin route.
8
10
  */
@@ -18,7 +20,15 @@ import {
18
20
  type PrefillResolver,
19
21
  } from '../../services/prefill-resolver'
20
22
  import { submissionStartInputSchema } from '../../data/validators'
21
- import { mapSubmissionError, readJsonBody, serializeActor, serializeRevision, serializeSubmission } from '../runtime-helpers'
23
+ import { isPortalSubjectAllowed } from '../../lib/portal-subject-policy'
24
+ import {
25
+ mapSubmissionError,
26
+ portalSubjectForbiddenResponse,
27
+ readJsonBody,
28
+ serializeActor,
29
+ serializeRevision,
30
+ serializeSubmission,
31
+ } from '../runtime-helpers'
22
32
 
23
33
  export const metadata = {
24
34
  POST: { requireAuth: false },
@@ -43,6 +53,14 @@ export async function POST(req: NextRequest) {
43
53
  { status: 422 },
44
54
  )
45
55
  }
56
+ if (
57
+ !isPortalSubjectAllowed(auth.sub, {
58
+ subjectType: parsed.data.subject_type,
59
+ subjectId: parsed.data.subject_id,
60
+ })
61
+ ) {
62
+ return portalSubjectForbiddenResponse()
63
+ }
46
64
 
47
65
  const container = await createRequestContainer()
48
66
  const service = container.resolve('formsSubmissionService') as SubmissionService
@@ -122,6 +140,7 @@ const postMethodDoc: OpenApiMethodDoc = {
122
140
  responses: [{ status: 201, description: 'Submission started', schema: submissionResponseSchema }],
123
141
  errors: [
124
142
  { status: 401, description: 'Not authenticated', schema: errorSchema },
143
+ { status: 403, description: 'SUBJECT_FORBIDDEN: subject is not the caller\'s own customer subject', schema: errorSchema },
125
144
  { status: 404, description: 'Form not found', schema: errorSchema },
126
145
  { status: 422, description: 'Validation or form-state error', schema: errorSchema },
127
146
  ],
@@ -9,6 +9,8 @@
9
9
  * then mark the invitation submitted. A capped distribution returns 410 BEFORE
10
10
  * any submission state changes. Customer-session submits skip the
11
11
  * reserve/mark-invitation steps — those are anonymous-distribution concerns.
12
+ * A customer-session submit of a submission the caller started themselves
13
+ * must carry their own subject (`lib/portal-subject-policy.ts`).
12
14
  */
13
15
 
14
16
  import { NextResponse, type NextRequest } from 'next/server'
@@ -23,7 +25,9 @@ import { submissionSubmitInputSchema } from '../../../../../data/validators'
23
25
  import { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'
24
26
  import {
25
27
  buildSubmitMetadata,
28
+ isPortalSubjectForbiddenError,
26
29
  mapDistributionError,
30
+ portalSubjectForbiddenResponse,
27
31
  readJsonBody,
28
32
  serializeSubmission,
29
33
  } from '../../../../runtime-helpers'
@@ -104,6 +108,7 @@ export async function POST(
104
108
  baseRevisionId: parsed.data.base_revision_id,
105
109
  submittedBy: principal.principal,
106
110
  submitMetadata: buildSubmitMetadata(req, parsed.data.submit_metadata),
111
+ submittedByPortalCustomer: principal.source === 'customer',
107
112
  })
108
113
 
109
114
  if (principal.source === 'token') {
@@ -121,6 +126,7 @@ export async function POST(
121
126
  redirect_url: distribution?.redirectUrl ?? null,
122
127
  })
123
128
  } catch (error) {
129
+ if (isPortalSubjectForbiddenError(error)) return portalSubjectForbiddenResponse()
124
130
  return mapDistributionError(error)
125
131
  }
126
132
  }
@@ -144,6 +150,7 @@ const postMethodDoc: OpenApiMethodDoc = {
144
150
  responses: [{ status: 200, description: 'Submission submitted', schema: responseSchema }],
145
151
  errors: [
146
152
  { status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },
153
+ { status: 403, description: 'SUBJECT_FORBIDDEN: customer-started submission is not on the caller\'s own customer subject', schema: errorSchema },
147
154
  { status: 404, description: 'Submission or invitation not found', schema: errorSchema },
148
155
  { status: 409, description: 'Stale base_revision_id', schema: errorSchema },
149
156
  { status: 410, description: 'Distribution response cap reached or unavailable', schema: errorSchema },