@open-mercato/core 0.9.1-develop.7513.1.ae2715553f → 0.9.1-develop.7516.1.176184247d

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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/communication_channels/lib/send-as-user.js.map +2 -2
  3. package/dist/modules/customers/api/people/[id]/emails/route.js +44 -1
  4. package/dist/modules/customers/api/people/[id]/emails/route.js.map +2 -2
  5. package/dist/modules/customers/lib/personEmailThreads.js +36 -1
  6. package/dist/modules/customers/lib/personEmailThreads.js.map +2 -2
  7. package/dist/modules/messages/api/[id]/route.js +3 -9
  8. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  9. package/dist/modules/messages/api/route.js +33 -16
  10. package/dist/modules/messages/api/route.js.map +2 -2
  11. package/dist/modules/messages/commands/messages.js.map +2 -2
  12. package/dist/modules/messages/lib/routeHelpers.js +40 -1
  13. package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
  14. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js +4 -7
  15. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js.map +2 -2
  16. package/dist/modules/shipping_carriers/lib/shipping-service.js +29 -11
  17. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  18. package/dist/modules/shipping_carriers/lib/status-sync.js +2 -2
  19. package/dist/modules/shipping_carriers/lib/status-sync.js.map +2 -2
  20. package/dist/modules/shipping_carriers/workers/webhook-processor.js +60 -33
  21. package/dist/modules/shipping_carriers/workers/webhook-processor.js.map +3 -3
  22. package/dist/modules/warranty_claims/commands/claim-lines.js +89 -68
  23. package/dist/modules/warranty_claims/commands/claim-lines.js.map +2 -2
  24. package/dist/modules/warranty_claims/commands/claims.js +14 -13
  25. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  26. package/dist/modules/warranty_claims/commands/shared.js +8 -2
  27. package/dist/modules/warranty_claims/commands/shared.js.map +2 -2
  28. package/package.json +7 -7
  29. package/src/modules/communication_channels/lib/send-as-user.ts +4 -0
  30. package/src/modules/customers/api/people/[id]/emails/route.ts +67 -1
  31. package/src/modules/customers/i18n/de.json +1 -0
  32. package/src/modules/customers/i18n/en.json +1 -0
  33. package/src/modules/customers/i18n/es.json +1 -0
  34. package/src/modules/customers/i18n/ko.json +1 -0
  35. package/src/modules/customers/i18n/pl.json +1 -0
  36. package/src/modules/customers/lib/personEmailThreads.ts +61 -0
  37. package/src/modules/messages/api/[id]/route.ts +2 -9
  38. package/src/modules/messages/api/route.ts +42 -20
  39. package/src/modules/messages/commands/messages.ts +7 -0
  40. package/src/modules/messages/i18n/de.json +1 -0
  41. package/src/modules/messages/i18n/en.json +1 -0
  42. package/src/modules/messages/i18n/es.json +1 -0
  43. package/src/modules/messages/i18n/ko.json +1 -0
  44. package/src/modules/messages/i18n/pl.json +1 -0
  45. package/src/modules/messages/lib/routeHelpers.ts +84 -1
  46. package/src/modules/shipping_carriers/api/webhook/[provider]/route.ts +4 -7
  47. package/src/modules/shipping_carriers/lib/shipping-service.ts +29 -11
  48. package/src/modules/shipping_carriers/lib/status-sync.ts +2 -2
  49. package/src/modules/shipping_carriers/workers/webhook-processor.ts +71 -39
  50. package/src/modules/warranty_claims/commands/claim-lines.ts +107 -66
  51. package/src/modules/warranty_claims/commands/claims.ts +14 -13
  52. package/src/modules/warranty_claims/commands/shared.ts +7 -1
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from "crypto";
2
- import { assertFound } from "@open-mercato/shared/lib/crud/errors";
2
+ import { CrudHttpError, assertFound } from "@open-mercato/shared/lib/crud/errors";
3
3
  import { invalidateCrudCache } from "@open-mercato/shared/lib/crud/cache";
4
4
  import { enforceCommandOptimisticLockWithGuards } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
5
5
  import { withAtomicFlush } from "@open-mercato/shared/lib/commands/flush";
6
6
  import { emitCrudSideEffects, flushCrudSideEffects } from "@open-mercato/shared/lib/commands/helpers";
7
7
  import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
8
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
8
9
  import {
9
10
  WarrantyClaim,
10
11
  WarrantyClaimEvent,
@@ -26,6 +27,10 @@ async function enforceWarrantyClaimOptimisticLock(ctx, record, resourceKind = WA
26
27
  request: ctx.request ?? null
27
28
  });
28
29
  }
30
+ async function throwUndoRefusal(key, fallback) {
31
+ const { translate } = await resolveTranslations();
32
+ throw new CrudHttpError(409, { error: translate(key, fallback), code: key });
33
+ }
29
34
  async function loadScopedClaim(em, id, scope, options = {}) {
30
35
  return findOneWithDecryption(
31
36
  em,
@@ -157,6 +162,7 @@ export {
157
162
  extractUndoPayload,
158
163
  loadScopedClaim,
159
164
  reconcileVendorRecoverySourceClaim,
160
- requireScopedClaim
165
+ requireScopedClaim,
166
+ throwUndoRefusal
161
167
  };
162
168
  //# sourceMappingURL=shared.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/warranty_claims/commands/shared.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError, assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function throwUndoRefusal(key: string, fallback: string): Promise<never> {\n const { translate } = await resolveTranslations()\n throw new CrudHttpError(409, { error: translate(key, fallback), code: key })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["assertFound"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.9.1-develop.7513.1.ae2715553f",
3
+ "version": "0.9.1-develop.7516.1.176184247d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -264,16 +264,16 @@
264
264
  "zod": "^4.4.3"
265
265
  },
266
266
  "peerDependencies": {
267
- "@open-mercato/ai-assistant": "0.9.1-develop.7513.1.ae2715553f",
268
- "@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
269
- "@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
267
+ "@open-mercato/ai-assistant": "0.9.1-develop.7516.1.176184247d",
268
+ "@open-mercato/shared": "0.9.1-develop.7516.1.176184247d",
269
+ "@open-mercato/ui": "0.9.1-develop.7516.1.176184247d",
270
270
  "react": "^19.0.0",
271
271
  "react-dom": "^19.0.0"
272
272
  },
273
273
  "devDependencies": {
274
- "@open-mercato/ai-assistant": "0.9.1-develop.7513.1.ae2715553f",
275
- "@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
276
- "@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
274
+ "@open-mercato/ai-assistant": "0.9.1-develop.7516.1.176184247d",
275
+ "@open-mercato/shared": "0.9.1-develop.7516.1.176184247d",
276
+ "@open-mercato/ui": "0.9.1-develop.7516.1.176184247d",
277
277
  "@testing-library/dom": "^10.4.1",
278
278
  "@testing-library/jest-dom": "^7.0.1",
279
279
  "@testing-library/react": "^16.3.3",
@@ -39,6 +39,10 @@ export type SendAsUserInput = {
39
39
  * derives `threadId` from the parent), so a CRM reply continues the existing
40
40
  * conversation instead of starting a new thread. Optional — omitted for new
41
41
  * threads.
42
+ *
43
+ * Not authorized here: the facade serves more than one surface, and each owns
44
+ * what "may reply to" means. The caller MUST have established that the actor
45
+ * can read this message under its own read rule before passing it.
42
46
  */
43
47
  parentMessageId?: string
44
48
  /**
@@ -12,8 +12,11 @@ import {
12
12
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
13
13
  import { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
14
14
  import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
15
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
15
16
  import { resolveAuthActorId } from '../../../../lib/interactionRequestContext'
16
17
  import { CustomerEntity } from '../../../../data/entities'
18
+ import { listGrantsForViewerOnPerson, listSharedChannelIds } from '../../../../lib/conversationShares'
19
+ import { isMessageOnPersonEmailHistory } from '../../../../lib/personEmailThreads'
17
20
  import type { SendAsUserService } from '@open-mercato/core/modules/communication_channels/lib/send-as-user'
18
21
 
19
22
  export const metadata = {
@@ -40,6 +43,29 @@ const composeSchema = z
40
43
  })
41
44
  .strict()
42
45
 
46
+ type RbacService = {
47
+ userHasAllFeatures: (
48
+ userId: string,
49
+ required: string[],
50
+ scope: { tenantId: string | null; organizationId: string | null },
51
+ ) => Promise<boolean>
52
+ }
53
+
54
+ async function callerHasFeature(
55
+ container: { resolve: (name: string) => unknown },
56
+ userId: string,
57
+ feature: string,
58
+ scope: { tenantId: string; organizationId: string | null },
59
+ ): Promise<boolean> {
60
+ try {
61
+ const rbac = container.resolve('rbacService') as RbacService | undefined
62
+ if (typeof rbac?.userHasAllFeatures !== 'function') return false
63
+ return await rbac.userHasAllFeatures(userId, [feature], scope)
64
+ } catch {
65
+ return false
66
+ }
67
+ }
68
+
43
69
  type RouteContext = {
44
70
  params: Promise<{ id: string }> | { id: string }
45
71
  }
@@ -96,6 +122,46 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
96
122
  return NextResponse.json({ error: 'Person not found' }, { status: 404 })
97
123
  }
98
124
 
125
+ // A reply parent decides which thread the sent email joins, so it must be an
126
+ // email this caller could see on this Person's Emails tab — the same feature,
127
+ // anchor and visibility rule as `GET .../email-threads`, including conversation
128
+ // shares and shared team mailboxes. Anything else answers like an unknown id.
129
+ if (body.parentMessageId) {
130
+ const viewerUserId = auth.isApiKey ? null : auth.sub ?? null
131
+ const shareScope = { tenantId: auth.tenantId as string, organizationId }
132
+ const canViewPeople = await callerHasFeature(container, userId, 'customers.people.view', shareScope)
133
+ const [sharedConversations, sharedChannelIds] = canViewPeople
134
+ ? await Promise.all([
135
+ listGrantsForViewerOnPerson(em, shareScope, viewerUserId, personId),
136
+ listSharedChannelIds(em, shareScope, viewerUserId),
137
+ ])
138
+ : [[], []]
139
+ const parentVisible =
140
+ canViewPeople &&
141
+ (await isMessageOnPersonEmailHistory(em, {
142
+ personId,
143
+ tenantId: auth.tenantId as string,
144
+ organizationId,
145
+ viewerUserId,
146
+ userFeatures: undefined,
147
+ sharedConversations,
148
+ sharedChannelIds,
149
+ messageId: body.parentMessageId,
150
+ }))
151
+ if (!parentVisible) {
152
+ const { translate } = await resolveTranslations()
153
+ return NextResponse.json(
154
+ {
155
+ error: translate(
156
+ 'customers.email.errors.parentMessageNotFound',
157
+ 'The email you are replying to was not found',
158
+ ),
159
+ },
160
+ { status: 404 },
161
+ )
162
+ }
163
+ }
164
+
99
165
  const guardResult = await validateCrudMutationGuard(container, {
100
166
  tenantId: auth.tenantId,
101
167
  organizationId,
@@ -178,7 +244,7 @@ export const openApi = {
178
244
  { status: 400, description: 'Invalid person id' },
179
245
  { status: 401, description: 'Unauthorized' },
180
246
  { status: 403, description: 'Missing customers.email.compose feature or mutation guard rejection' },
181
- { status: 404, description: 'Person or channel not found' },
247
+ { status: 404, description: 'Person or channel not found, or the reply parent is not an email the caller can see on this Person' },
182
248
  { status: 409, description: 'Channel not connected' },
183
249
  { status: 422, description: 'Invalid request body' },
184
250
  { status: 500, description: 'Send failed' },
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "Von einem Kollegen geteilt",
1692
1692
  "customers.email.errors.deliveryFailed": "Zustellung fehlgeschlagen – nicht gesendet",
1693
1693
  "customers.email.errors.flipFailed": "Aktualisierung der Sichtbarkeit fehlgeschlagen",
1694
+ "customers.email.errors.parentMessageNotFound": "Die E-Mail, auf die Sie antworten, wurde nicht gefunden",
1694
1695
  "customers.email.errors.sendFailed": "Senden fehlgeschlagen",
1695
1696
  "customers.email.sync.button": "Synchronisieren",
1696
1697
  "customers.email.sync.failed": "Postfach konnte nicht synchronisiert werden",
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "Shared by a teammate",
1692
1692
  "customers.email.errors.deliveryFailed": "Delivery failed — not sent",
1693
1693
  "customers.email.errors.flipFailed": "Visibility update failed",
1694
+ "customers.email.errors.parentMessageNotFound": "The email you are replying to was not found",
1694
1695
  "customers.email.errors.sendFailed": "Send failed",
1695
1696
  "customers.email.sync.button": "Sync",
1696
1697
  "customers.email.sync.failed": "Failed to sync mailbox",
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "Compartido por un compañero",
1692
1692
  "customers.email.errors.deliveryFailed": "Error de entrega: no enviado",
1693
1693
  "customers.email.errors.flipFailed": "Error al actualizar la visibilidad",
1694
+ "customers.email.errors.parentMessageNotFound": "No se encontró el correo al que está respondiendo",
1694
1695
  "customers.email.errors.sendFailed": "Error al enviar",
1695
1696
  "customers.email.sync.button": "Sincronizar",
1696
1697
  "customers.email.sync.failed": "No se pudo sincronizar el buzón",
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "동료가 공유",
1692
1692
  "customers.email.errors.deliveryFailed": "전송 실패 — 발송되지 않음",
1693
1693
  "customers.email.errors.flipFailed": "공개 범위 업데이트 실패",
1694
+ "customers.email.errors.parentMessageNotFound": "답장하려는 이메일을 찾을 수 없습니다",
1694
1695
  "customers.email.errors.sendFailed": "전송 실패",
1695
1696
  "customers.email.sync.button": "동기화",
1696
1697
  "customers.email.sync.failed": "메일함 동기화에 실패했습니다",
@@ -1691,6 +1691,7 @@
1691
1691
  "customers.email.conversationShare.sharedByTeammate": "Udostępnione przez współpracownika",
1692
1692
  "customers.email.errors.deliveryFailed": "Dostarczanie nie powiodło się – nie wysłano",
1693
1693
  "customers.email.errors.flipFailed": "Aktualizacja widoczności nieudana",
1694
+ "customers.email.errors.parentMessageNotFound": "Nie znaleziono e-maila, na który odpowiadasz",
1694
1695
  "customers.email.errors.sendFailed": "Wysyłanie nieudane",
1695
1696
  "customers.email.sync.button": "Synchronizuj",
1696
1697
  "customers.email.sync.failed": "Nie udało się zsynchronizować skrzynki",
@@ -378,3 +378,64 @@ export async function buildPersonEmailThreads(
378
378
  threads.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
379
379
  return threads.slice(0, maxThreads)
380
380
  }
381
+
382
+ export type PersonEmailMessageAccessOptions = Pick<
383
+ BuildPersonEmailThreadsOptions,
384
+ 'personId' | 'tenantId' | 'organizationId' | 'viewerUserId' | 'userFeatures' | 'sharedConversations' | 'sharedChannelIds'
385
+ > & {
386
+ /** Open Mercato `messages.message` id the caller wants to reply to. */
387
+ messageId: string
388
+ }
389
+
390
+ /**
391
+ * Whether `messageId` is on this Person's email history as the viewer sees it —
392
+ * i.e. whether {@link buildPersonEmailThreads} would show it to them.
393
+ *
394
+ * Walks the same anchor (email `CustomerInteraction` → `MessageChannelLink` →
395
+ * message) with the same scope and the same visibility predicate, so a reply
396
+ * from the Person page can only name a parent the Emails tab could have shown:
397
+ * the viewer's own mail, shared or legacy mail, a conversation shared for THIS
398
+ * Person, or a shared team mailbox. Callers remain responsible for resolving the
399
+ * Person in scope first.
400
+ */
401
+ export async function isMessageOnPersonEmailHistory(
402
+ em: EntityManager,
403
+ opts: PersonEmailMessageAccessOptions,
404
+ ): Promise<boolean> {
405
+ const { personId, tenantId, organizationId, messageId } = opts
406
+ const dscope = { tenantId, organizationId: organizationId ?? null }
407
+
408
+ const linkWhere: JsonRecord = { messageId, tenantId }
409
+ if (organizationId) linkWhere.organizationId = organizationId
410
+ const links = (await findWithDecryption(
411
+ em,
412
+ 'MessageChannelLink' as never,
413
+ linkWhere as never,
414
+ undefined,
415
+ dscope,
416
+ )) as JsonRecord[]
417
+ const linkIds = links
418
+ .map((link) => (typeof link.id === 'string' ? (link.id as string) : null))
419
+ .filter((value): value is string => !!value)
420
+ if (linkIds.length === 0) return false
421
+
422
+ const interactionWhere: JsonRecord = {
423
+ entity: personId,
424
+ interactionType: 'email',
425
+ deletedAt: null,
426
+ tenantId,
427
+ externalMessageId: { $in: linkIds },
428
+ }
429
+ if (organizationId) interactionWhere.organizationId = organizationId
430
+ Object.assign(
431
+ interactionWhere,
432
+ buildEmailVisibilityMikroFilter({
433
+ currentUserId: opts.viewerUserId,
434
+ userFeatures: opts.userFeatures,
435
+ sharedConversations: opts.sharedConversations,
436
+ sharedChannelIds: opts.sharedChannelIds,
437
+ }),
438
+ )
439
+ const visible = await em.count(CustomerInteraction, interactionWhere as never)
440
+ return visible > 0
441
+ }
@@ -16,9 +16,9 @@ import { getMessageTypeOrDefault } from '../../lib/message-types-registry'
16
16
  import { attachOperationMetadataHeader } from '../../lib/operationMetadata'
17
17
  import {
18
18
  canPostToChannelThread,
19
- hasChannelThreadReadAccess,
20
19
  hasOrganizationAccess,
21
20
  resolveMessageContext,
21
+ resolveMessageReadAccess,
22
22
  } from '../../lib/routeHelpers'
23
23
  import { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from '../guards'
24
24
  import {
@@ -103,13 +103,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
103
103
  return Response.json({ error: 'Access denied' }, { status: 403 })
104
104
  }
105
105
 
106
- const recipient = await em.findOne(MessageRecipient, {
107
- messageId: params.id,
108
- recipientUserId: scope.userId,
109
- deletedAt: null,
110
- })
111
-
112
- const isSender = message.senderUserId === scope.userId
106
+ const { recipient, isSender, hasChannelThreadAccess } = await resolveMessageReadAccess(ctx, scope, message)
113
107
  const isRecipient = Boolean(recipient)
114
108
 
115
109
  // #5535: a message that arrived over a communication channel has the channel
@@ -126,7 +120,6 @@ export async function GET(req: Request, { params }: { params: { id: string } })
126
120
  // `requireAuth` only so that a participant can always read their own message.
127
121
  // The gate goes through RBAC, not through `ctx.auth.features` — the session JWT
128
122
  // carries no `features` claim at all, so reading it would deny everyone.
129
- const hasChannelThreadAccess = await hasChannelThreadReadAccess(ctx, scope, message)
130
123
 
131
124
  if (!isSender && !isRecipient && !hasChannelThreadAccess) {
132
125
  return Response.json({ error: 'Access denied' }, { status: 403 })
@@ -12,7 +12,7 @@ import {
12
12
  resolveCrudCache,
13
13
  } from '@open-mercato/shared/lib/crud/cache'
14
14
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
15
- import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
15
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
16
16
  import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
17
17
  import { User } from '../../auth/data/entities'
18
18
  import { Message, MessageObject } from '../data/entities'
@@ -36,6 +36,7 @@ import { attachOperationMetadataHeader } from '../lib/operationMetadata'
36
36
  import {
37
37
  canPostToChannelThread,
38
38
  canUseChannelThreadFallback,
39
+ resolveReplyParentMessage,
39
40
  canUseMessageEmailFeature,
40
41
  resolveMessageContext,
41
42
  } from '../lib/routeHelpers'
@@ -46,6 +47,7 @@ import { MessageCommandExecuteResult } from '../commands/shared'
46
47
  import {
47
48
  composeMessageRequestSchema as composeSchema,
48
49
  composeResponseSchema,
50
+ errorResponseSchema,
49
51
  listMessagesSchema as listSchema,
50
52
  messageListItemSchema,
51
53
  } from './openapi'
@@ -510,6 +512,35 @@ export async function POST(req: Request) {
510
512
  }
511
513
  }
512
514
 
515
+ // A parent decides which conversation the new message joins, so it is held to
516
+ // the same rule as reading it: knowing a message id is not enough to thread
517
+ // onto someone else's conversation. Checked on the parent the client sent, for
518
+ // drafts and every visibility, before any guard, delegation or write runs.
519
+ let replyParent: Message | null = null
520
+ if (input.parentMessageId) {
521
+ const resolution = await resolveReplyParentMessage(
522
+ ctx,
523
+ { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null, userId: scope.userId },
524
+ input.parentMessageId,
525
+ )
526
+ if (resolution.status === 'not_found') {
527
+ const { translate } = await resolveTranslations()
528
+ return Response.json(
529
+ {
530
+ error: translate(
531
+ 'messages.errors.parentMessageNotFound',
532
+ 'The message you are replying to was not found',
533
+ ),
534
+ },
535
+ { status: 404 },
536
+ )
537
+ }
538
+ if (resolution.status === 'forbidden') {
539
+ return Response.json({ error: 'Access denied' }, { status: 403 })
540
+ }
541
+ replyParent = resolution.message
542
+ }
543
+
513
544
  // #5535: composing on a channel conversation without naming a parent message
514
545
  // used to open a brand-new thread. A fresh thread has no `ChannelThreadMapping`,
515
546
  // so the outbound bridge had nothing to route on and the operator got a 201 for
@@ -555,27 +586,14 @@ export async function POST(req: Request) {
555
586
  // derives the thread from that parent alone — so a public, non-draft message
556
587
  // could be threaded onto a channel-linked conversation and delivered to the
557
588
  // external correspondent without any channel-access check. A tenant-wide
558
- // channel accepts delivery from any sender, so `messages.compose` plus a known
559
- // message id was enough. Apply the same check to the thread the command will
560
- // actually derive. An internal thread, a thread outside the caller's scope,
561
- // and an absent hub all resolve to `null` and keep the pre-existing rule.
589
+ // channel accepts delivery from any sender, so reading a message is not
590
+ // enough to post onto its thread. Apply the same check to the thread the
591
+ // command will actually derive. An internal thread and an absent hub resolve
592
+ // to `null` and keep the pre-existing rule.
562
593
  // Both channel branches also require `messages.view`, like reply and forward:
563
594
  // the hub grants every shared channel regardless of features (#6432).
564
- if (isPublicVisibility && !input.isDraft && input.parentMessageId) {
565
- const em = ctx.container.resolve('em') as EntityManager
566
- const parentMessage = await findOneWithDecryption(
567
- em,
568
- Message,
569
- {
570
- id: input.parentMessageId,
571
- tenantId: scope.tenantId,
572
- organizationId: scope.organizationId,
573
- deletedAt: null,
574
- },
575
- undefined,
576
- { tenantId: scope.tenantId, organizationId: scope.organizationId },
577
- )
578
- const parentThreadId = parentMessage?.threadId ?? input.parentMessageId
595
+ if (isPublicVisibility && !input.isDraft && replyParent) {
596
+ const parentThreadId = replyParent.threadId ?? replyParent.id
579
597
  if (!(await canPostToChannelThread(ctx, scope, parentThreadId))) {
580
598
  return Response.json({ error: 'Access denied' }, { status: 403 })
581
599
  }
@@ -711,6 +729,10 @@ export const openApi: OpenApiRouteDoc = {
711
729
  schema: composeResponseSchema,
712
730
  },
713
731
  ],
732
+ errors: [
733
+ { status: 403, description: 'Access denied, including a parentMessageId the caller cannot read', schema: errorResponseSchema },
734
+ { status: 404, description: 'parentMessageId is unknown, deleted, or outside the caller organization', schema: errorResponseSchema },
735
+ ],
714
736
  },
715
737
  },
716
738
  }
@@ -266,6 +266,13 @@ async function buildComposeResultFromExisting(
266
266
  }
267
267
  }
268
268
 
269
+ /**
270
+ * `parentMessageId` is trusted: the thread is derived from it without a read
271
+ * check, because the acting `userId` is itself an input and server-side callers
272
+ * (channel ingest) pass parents the actor is not a participant of. Every surface
273
+ * that accepts a parent from a caller MUST resolve it against its own read rule
274
+ * first (`resolveReplyParentMessage` for the messages API).
275
+ */
269
276
  const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: string | null; externalEmail: string | null; isDraft: boolean; recipientUserIds: string[]; deduplicated?: boolean }> = {
270
277
  id: 'messages.messages.compose',
271
278
  async execute(rawInput, ctx) {
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Bitte gib eine gültige externe E-Mail ein.",
131
131
  "messages.errors.noRecipients": "Füge mindestens einen Empfänger hinzu.",
132
132
  "messages.errors.noSubject": "Bitte Betreff eingeben.",
133
+ "messages.errors.parentMessageNotFound": "Die Nachricht, auf die Sie antworten, wurde nicht gefunden",
133
134
  "messages.errors.sendFailed": "Nachricht konnte nicht gesendet werden.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Das Senden eines gespeicherten Entwurfs über ein verbundenes Postfach wird noch nicht unterstützt.",
135
136
  "messages.errors.senderPayloadUnsupported": "Der Versand aus dem eigenen Postfach unterstützt noch keine Anhänge oder verknüpften Datensätze.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Please enter a valid external email.",
131
131
  "messages.errors.noRecipients": "Please add at least one recipient.",
132
132
  "messages.errors.noSubject": "Please enter a subject.",
133
+ "messages.errors.parentMessageNotFound": "The message you are replying to was not found",
133
134
  "messages.errors.sendFailed": "Failed to send message.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Sending a saved draft through a connected mailbox is not supported yet.",
135
136
  "messages.errors.senderPayloadUnsupported": "Sending from your own mailbox does not support attachments or linked records yet.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Introduce un correo externo válido.",
131
131
  "messages.errors.noRecipients": "Añade al menos un destinatario.",
132
132
  "messages.errors.noSubject": "Introduce un asunto.",
133
+ "messages.errors.parentMessageNotFound": "No se encontró el mensaje al que está respondiendo",
133
134
  "messages.errors.sendFailed": "No se pudo enviar el mensaje.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Enviar un borrador guardado desde un buzón conectado todavía no es compatible.",
135
136
  "messages.errors.senderPayloadUnsupported": "El envío desde tu propio buzón todavía no admite adjuntos ni registros vinculados.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "유효한 외부 이메일을 입력하세요.",
131
131
  "messages.errors.noRecipients": "수신자를 하나 이상 추가하세요.",
132
132
  "messages.errors.noSubject": "제목을 입력하세요.",
133
+ "messages.errors.parentMessageNotFound": "답장하려는 메시지를 찾을 수 없습니다",
133
134
  "messages.errors.sendFailed": "메시지 전송에 실패했습니다.",
134
135
  "messages.errors.senderDraftSendUnsupported": "저장된 초안을 연결된 메일함으로 보내는 기능은 아직 지원되지 않습니다.",
135
136
  "messages.errors.senderPayloadUnsupported": "본인 메일함에서 보내기는 아직 첨부 파일이나 연결된 레코드를 지원하지 않습니다.",
@@ -130,6 +130,7 @@
130
130
  "messages.errors.noExternalEmail": "Wpisz poprawny zewnętrzny adres e-mail.",
131
131
  "messages.errors.noRecipients": "Dodaj przynajmniej jednego odbiorcę.",
132
132
  "messages.errors.noSubject": "Wpisz temat.",
133
+ "messages.errors.parentMessageNotFound": "Nie znaleziono wiadomości, na którą odpowiadasz",
133
134
  "messages.errors.sendFailed": "Nie udało się wysłać wiadomości.",
134
135
  "messages.errors.senderDraftSendUnsupported": "Wysyłka zapisanego szkicu przez połączoną skrzynkę nie jest jeszcze obsługiwana.",
135
136
  "messages.errors.senderPayloadUnsupported": "Wysyłka z własnej skrzynki nie obsługuje jeszcze załączników ani powiązanych rekordów.",