@open-mercato/core 0.7.1-develop.7131.1.20abf9e885 → 0.7.1-develop.7135.1.19bf969756

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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  3. package/dist/modules/communication_channels/api/post/test-seed/route.js +40 -2
  4. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  5. package/dist/modules/communication_channels/lib/access-control.js +6 -1
  6. package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
  7. package/dist/modules/communication_channels/lib/system-user.js +8 -3
  8. package/dist/modules/communication_channels/lib/system-user.js.map +2 -2
  9. package/dist/modules/warranty_claims/api/ai/assess/route.js +20 -18
  10. package/dist/modules/warranty_claims/api/ai/assess/route.js.map +2 -2
  11. package/dist/modules/warranty_claims/api/credit-memo/route.js +11 -12
  12. package/dist/modules/warranty_claims/api/credit-memo/route.js.map +2 -2
  13. package/dist/modules/warranty_claims/api/events/route.js +1 -1
  14. package/dist/modules/warranty_claims/api/events/route.js.map +2 -2
  15. package/dist/modules/warranty_claims/api/portal/attachments/route.js +24 -27
  16. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  17. package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js +8 -10
  18. package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js.map +2 -2
  19. package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js +4 -9
  20. package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js.map +2 -2
  21. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js +3 -3
  22. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js.map +2 -2
  23. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js +3 -3
  24. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js.map +2 -2
  25. package/dist/modules/warranty_claims/api/portal/claims/route.js +4 -9
  26. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +2 -2
  27. package/dist/modules/warranty_claims/api/portal/events/route.js +12 -13
  28. package/dist/modules/warranty_claims/api/portal/events/route.js.map +2 -2
  29. package/dist/modules/warranty_claims/api/receiving/route.js +14 -15
  30. package/dist/modules/warranty_claims/api/receiving/route.js.map +2 -2
  31. package/dist/modules/warranty_claims/api/registrations/route.js +9 -4
  32. package/dist/modules/warranty_claims/api/registrations/route.js.map +2 -2
  33. package/dist/modules/warranty_claims/api/replacement-order/route.js +11 -12
  34. package/dist/modules/warranty_claims/api/replacement-order/route.js.map +2 -2
  35. package/dist/modules/warranty_claims/api/return-label/route.js +20 -19
  36. package/dist/modules/warranty_claims/api/return-label/route.js.map +2 -2
  37. package/dist/modules/warranty_claims/api/risk/route.js +2 -1
  38. package/dist/modules/warranty_claims/api/risk/route.js.map +2 -2
  39. package/dist/modules/warranty_claims/api/sales-return/route.js +11 -12
  40. package/dist/modules/warranty_claims/api/sales-return/route.js.map +2 -2
  41. package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js +22 -1
  42. package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js.map +2 -2
  43. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js +2 -1
  44. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js.map +2 -2
  45. package/dist/modules/warranty_claims/commands/shared.js +2 -2
  46. package/dist/modules/warranty_claims/commands/shared.js.map +2 -2
  47. package/dist/modules/warranty_claims/lib/portalAuthGuard.js +31 -0
  48. package/dist/modules/warranty_claims/lib/portalAuthGuard.js.map +7 -0
  49. package/package.json +7 -7
  50. package/src/helpers/integration/communicationChannelsFixtures.ts +13 -0
  51. package/src/modules/communication_channels/api/post/test-seed/route.ts +47 -2
  52. package/src/modules/communication_channels/lib/access-control.ts +28 -0
  53. package/src/modules/communication_channels/lib/system-user.ts +40 -22
  54. package/src/modules/warranty_claims/api/ai/assess/route.ts +23 -17
  55. package/src/modules/warranty_claims/api/credit-memo/route.ts +14 -13
  56. package/src/modules/warranty_claims/api/events/route.ts +1 -1
  57. package/src/modules/warranty_claims/api/portal/attachments/route.ts +29 -27
  58. package/src/modules/warranty_claims/api/portal/claims/[id]/route.ts +9 -10
  59. package/src/modules/warranty_claims/api/portal/claims/[id]/shared.ts +5 -9
  60. package/src/modules/warranty_claims/api/portal/claims/[id]/submit/route.ts +3 -3
  61. package/src/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.ts +3 -3
  62. package/src/modules/warranty_claims/api/portal/claims/route.ts +5 -9
  63. package/src/modules/warranty_claims/api/portal/events/route.ts +13 -13
  64. package/src/modules/warranty_claims/api/receiving/route.ts +17 -15
  65. package/src/modules/warranty_claims/api/registrations/route.ts +9 -3
  66. package/src/modules/warranty_claims/api/replacement-order/route.ts +14 -13
  67. package/src/modules/warranty_claims/api/return-label/route.ts +21 -17
  68. package/src/modules/warranty_claims/api/risk/route.ts +3 -1
  69. package/src/modules/warranty_claims/api/sales-return/route.ts +14 -13
  70. package/src/modules/warranty_claims/api/troubleshooting-guides/route.ts +23 -1
  71. package/src/modules/warranty_claims/api/vendor-recovery-suggestions/route.ts +3 -1
  72. package/src/modules/warranty_claims/commands/shared.ts +7 -1
  73. package/src/modules/warranty_claims/lib/portalAuthGuard.ts +30 -0
@@ -59,6 +59,34 @@ export function channelOrgScopeWhereFromFilter(
59
59
  return { $or: [{ organizationId: { $in: organizationIds } }, { organizationId: null }] }
60
60
  }
61
61
 
62
+ /**
63
+ * Ownership fragment for LISTING channels — the SQL-layer twin of
64
+ * {@link assertCanAccessChannel}.
65
+ *
66
+ * Routes have historically hardcoded one of the two halves of the rule: the
67
+ * admin channel list narrows to `user_id IS NULL`, the profile list narrows to
68
+ * `user_id = <caller>`. A provider-scoped listing needs both, because which half
69
+ * a channel falls into is decided by the connect flow the provider offers, not
70
+ * by the listing. Hardcoding `userId: null` is what made the Discord AI
71
+ * auto-reply panel structurally empty (#5602): every route the product exposes
72
+ * for connecting a Discord bot writes `user_id = auth.sub`, so the filter
73
+ * excluded the only channels that can exist.
74
+ *
75
+ * The clause admits exactly what the assert allows and nothing more — shared
76
+ * channels, plus the caller's own personal ones. It grants no admin bypass, in
77
+ * line with v1 strict owner-only: a personal mailbox stays invisible to everyone
78
+ * but its owner. An anonymous caller (no user id) sees shared channels only.
79
+ *
80
+ * Spread into a MikroORM `where`, or nest under `$and` when another fragment in
81
+ * the same query already claims `$or` (`channelOrgScopeWhereFromFilter` does).
82
+ */
83
+ export function channelOwnerScopeWhere(
84
+ currentUserId: string | null | undefined,
85
+ ): Record<string, unknown> {
86
+ if (!currentUserId) return { userId: null }
87
+ return { $or: [{ userId: null }, { userId: currentUserId }] }
88
+ }
89
+
62
90
  /**
63
91
  * Throws when the caller may not access a specific channel. Returns silently
64
92
  * when access is allowed.
@@ -1,4 +1,5 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
2
3
 
3
4
  /**
4
5
  * Sentinel UUID — used as a last-resort `senderUserId` for inbound channel
@@ -31,6 +32,12 @@ export function systemUserEmail(tenantId: string): string {
31
32
  * sentinel. The inbound-processor must never refuse to ingest a message
32
33
  * because the channel-bot user doesn't exist.
33
34
  *
35
+ * That fail-soft design is also what hid #5599 for as long as it did — a broken
36
+ * lookup and an absent user produce the identical outcome — so the two
37
+ * properties the lookup depends on (the column it matches and the table it
38
+ * names) are pinned by `__tests__/system-user.test.ts` rather than left to the
39
+ * next reader to notice.
40
+ *
34
41
  * @param em EntityManager scoped to the tenant.
35
42
  * @param tenantId Tenant id for which to resolve the system user.
36
43
  * @param fallbackId Optional caller-supplied fallback (e.g., the channel's
@@ -43,28 +50,39 @@ export async function resolveCommunicationChannelsSystemUserId(
43
50
  ): Promise<string> {
44
51
  try {
45
52
  const expectedEmail = systemUserEmail(tenantId)
46
- // Untyped QB by design — the helper is intentionally cross-module
47
- // (resolving an `auth.user` from the hub) and must not pull the User
48
- // entity class. MikroORM v7's typed builder requires an entity ref;
49
- // we keep the lookup table-name-driven so the helper compiles without
50
- // a cross-module import. The mocks in `__tests__/system-user.test.ts`
51
- // exercise this code path through a duck-typed `createQueryBuilder` stub.
52
- type RawQueryBuilder = {
53
- select: (fields: string[]) => RawQueryBuilder
54
- where: (cond: Record<string, unknown>) => RawQueryBuilder
55
- limit: (count: number) => RawQueryBuilder
56
- execute: (mode: string) => Promise<unknown>
57
- }
58
- const qb = (
59
- em as unknown as { createQueryBuilder: (table: string, alias: string) => RawQueryBuilder }
60
- ).createQueryBuilder('auth.users', 'u')
61
- const row = await qb
62
- .select(['u.id'])
63
- .where({ email: expectedEmail, tenant_id: tenantId })
64
- .limit(1)
65
- .execute('get')
66
- .catch(() => null)
67
- const id = (row as { id?: string } | null)?.id
53
+ // Match on `email_hash`, never on `email` (#5599). `users.email` is
54
+ // encrypted at rest with a per-row IV, so its ciphertext is
55
+ // non-deterministic and an equality filter against the plaintext can never
56
+ // hit — which is exactly why the tenant uniqueness index keys on
57
+ // `email_hash` instead (see `auth/data/entities.ts`). The candidate list
58
+ // covers both the keyed `v2:` digest and the legacy unkeyed one, so a
59
+ // deployment that has not yet backfilled still matches.
60
+ //
61
+ // The digests must be computed the same way `auth` writes them
62
+ // (`auth/lib/emailHash.ts` → `hashForLookup(email)`), i.e. with NO hash
63
+ // context. Reusing the shared primitive rather than importing the auth
64
+ // helper keeps this module free of a cross-module code dependency; the two
65
+ // must stay aligned if `auth` ever adopts a context.
66
+ const emailHashes = lookupHashCandidates(expectedEmail)
67
+
68
+ // Raw SQL against the table, not `createQueryBuilder('auth.users')`. That
69
+ // call names no registered entity — entities are discovered under their
70
+ // class names — so MikroORM reads the dot as a schema qualifier and looks
71
+ // for `users` in a schema named `auth`, which this project never creates
72
+ // (`Migration20251030150038` puts `users` in the default schema). The helper
73
+ // swallows every error by design, so the miss was indistinguishable from
74
+ // "no channel-bot user exists" and hid behind the same fallback as the
75
+ // plaintext-vs-ciphertext defect above. A parameterized statement names the
76
+ // table unambiguously and still pulls in no cross-module entity class,
77
+ // keeping the decoupling this helper was written for.
78
+ const placeholders = emailHashes.map(() => '?').join(', ')
79
+ const rows = await em
80
+ .getConnection()
81
+ .execute<Array<{ id?: string }>>(
82
+ `SELECT id FROM users WHERE email_hash IN (${placeholders}) AND tenant_id = ? AND deleted_at IS NULL LIMIT 1`,
83
+ [...emailHashes, tenantId],
84
+ )
85
+ const id = Array.isArray(rows) ? rows[0]?.id : undefined
68
86
  if (typeof id === 'string' && id.length > 0) return id
69
87
  } catch {
70
88
  // ignore — fall through to fallback
@@ -9,6 +9,7 @@ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib
9
9
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
10
  import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
11
11
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
12
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
12
13
  import { runRouteMutationGuards, type RouteMutationGuardResult } from '@open-mercato/shared/lib/crud/route-mutation-guard'
13
14
  import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
14
15
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -110,7 +111,7 @@ function isSuperAdmin(auth: CommandRuntimeContext['auth']): boolean {
110
111
  function buildAuthContext(context: AssessRouteContext): AiChatRequestContext {
111
112
  const userId = context.ctx.auth?.sub
112
113
  if (!userId) {
113
- throw new CrudHttpError(401, { error: 'warranty_claims.errors.unauthorized' })
114
+ throw new CrudHttpError(401, { error: context.translate('warranty_claims.errors.unauthorized', 'Unauthorized') })
114
115
  }
115
116
  return {
116
117
  tenantId: context.tenantId,
@@ -124,7 +125,7 @@ function buildAuthContext(context: AssessRouteContext): AiChatRequestContext {
124
125
  function buildAttachmentAuthContext(context: AssessRouteContext): Exclude<AuthContext, null> {
125
126
  const auth = context.ctx.auth
126
127
  if (!auth?.sub) {
127
- throw new CrudHttpError(401, { error: 'warranty_claims.errors.unauthorized' })
128
+ throw new CrudHttpError(401, { error: context.translate('warranty_claims.errors.unauthorized', 'Unauthorized') })
128
129
  }
129
130
  return {
130
131
  ...auth,
@@ -134,16 +135,19 @@ function buildAttachmentAuthContext(context: AssessRouteContext): Exclude<AuthCo
134
135
  }
135
136
  }
136
137
 
137
- async function resolveAssessContext(req: Request): Promise<AssessRouteContext> {
138
+ async function resolveAssessContext(
139
+ req: Request,
140
+ translate: AssessRouteContext['translate'],
141
+ ): Promise<AssessRouteContext> {
138
142
  const container = await createRequestContainer()
139
143
  const auth = await getAuthFromRequest(req)
140
144
  if (!auth || !auth.tenantId) {
141
- throw new CrudHttpError(401, { error: 'warranty_claims.errors.unauthorized' })
145
+ throw new CrudHttpError(401, { error: translate('warranty_claims.errors.unauthorized', 'Unauthorized') })
142
146
  }
143
147
  const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
144
148
  const organizationId = scope?.selectedId ?? auth.orgId ?? null
145
149
  if (!organizationId) {
146
- throw new CrudHttpError(400, { error: 'warranty_claims.errors.organization_required' })
150
+ throw new CrudHttpError(400, { error: translate('warranty_claims.errors.organization_required', 'Organization context is required.') })
147
151
  }
148
152
  return {
149
153
  ctx: {
@@ -158,7 +162,7 @@ async function resolveAssessContext(req: Request): Promise<AssessRouteContext> {
158
162
  organizationId,
159
163
  container,
160
164
  em: container.resolve<EntityManager>('em').fork(),
161
- translate: (key: string) => key,
165
+ translate,
162
166
  }
163
167
  }
164
168
 
@@ -173,7 +177,7 @@ async function runGuard(
173
177
  ): Promise<RouteMutationGuardResult> {
174
178
  const userId = context.ctx.auth?.sub
175
179
  if (!userId) {
176
- throw new CrudHttpError(401, { error: 'warranty_claims.errors.unauthorized' })
180
+ throw new CrudHttpError(401, { error: context.translate('warranty_claims.errors.unauthorized', 'Unauthorized') })
177
181
  }
178
182
  return runRouteMutationGuards({
179
183
  container: context.ctx.container,
@@ -197,6 +201,7 @@ async function loadScopedLine(
197
201
  scope: WarrantyClaimScope,
198
202
  claimId: string,
199
203
  lineId: string,
204
+ translate: AssessRouteContext['translate'],
200
205
  ): Promise<WarrantyClaimLine> {
201
206
  const line = await findOneWithDecryption(
202
207
  em,
@@ -212,7 +217,7 @@ async function loadScopedLine(
212
217
  scope,
213
218
  )
214
219
  if (!line) {
215
- throw new CrudHttpError(404, { error: 'warranty_claims.errors.notFound' })
220
+ throw new CrudHttpError(404, { error: translate('warranty_claims.errors.notFound', 'Claim not found.') })
216
221
  }
217
222
  return line
218
223
  }
@@ -238,7 +243,7 @@ async function verifyAttachmentLinkedToTarget(
238
243
  ],
239
244
  }) : false
240
245
  if (!linked) {
241
- throw new CrudHttpError(400, { error: 'warranty_claims.errors.attachmentNotLinked' })
246
+ throw new CrudHttpError(400, { error: context.translate('warranty_claims.errors.attachmentNotLinked', 'The attachment is not linked to this claim.') })
242
247
  }
243
248
  }
244
249
 
@@ -279,12 +284,13 @@ async function persistAssessmentPayload(
279
284
  }
280
285
 
281
286
  export async function POST(req: Request) {
287
+ const { translate } = await resolveTranslations()
282
288
  try {
283
- const context = await resolveAssessContext(req)
289
+ const context = await resolveAssessContext(req, translate)
284
290
  const payload = toRecord(await readJsonSafe(req, {}))
285
291
  const parsed = toAssessInput(payload, context)
286
292
  if (parsed.kind === 'damage' && !parsed.lineId) {
287
- throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidInput' })
293
+ throw new CrudHttpError(400, { error: translate('warranty_claims.errors.invalidInput', 'Invalid input') })
288
294
  }
289
295
 
290
296
  const guarded = parsed.lineId ? await runGuard(req, context, parsed as AssessBodyInput & { lineId: string }) : null
@@ -296,9 +302,9 @@ export async function POST(req: Request) {
296
302
 
297
303
  if (input.kind === 'damage') {
298
304
  if (!input.lineId) {
299
- throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidInput' })
305
+ throw new CrudHttpError(400, { error: translate('warranty_claims.errors.invalidInput', 'Invalid input') })
300
306
  }
301
- const line = await loadScopedLine(context.em, scope, input.claimId, input.lineId)
307
+ const line = await loadScopedLine(context.em, scope, input.claimId, input.lineId, translate)
302
308
  await verifyAttachmentLinkedToTarget(context, {
303
309
  attachmentId: input.attachmentId,
304
310
  claimId: input.claimId,
@@ -318,8 +324,8 @@ export async function POST(req: Request) {
318
324
  return NextResponse.json({ status: 'ok', assessment })
319
325
  }
320
326
 
321
- await requireScopedClaim(context.em, input.claimId, scope)
322
- const line = input.lineId ? await loadScopedLine(context.em, scope, input.claimId, input.lineId) : null
327
+ await requireScopedClaim(context.em, input.claimId, scope, {}, translate('warranty_claims.errors.notFound', 'Claim not found.'))
328
+ const line = input.lineId ? await loadScopedLine(context.em, scope, input.claimId, input.lineId, translate) : null
323
329
  await verifyAttachmentLinkedToTarget(context, {
324
330
  attachmentId: input.attachmentId,
325
331
  claimId: input.claimId,
@@ -350,10 +356,10 @@ export async function POST(req: Request) {
350
356
  return NextResponse.json({ status: 'aiUnavailable' })
351
357
  }
352
358
  if (err instanceof z.ZodError) {
353
- return NextResponse.json({ error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
359
+ return NextResponse.json({ error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
354
360
  }
355
361
  logger.error('warranty_claims.ai.assess.post failed', { err })
356
- return NextResponse.json({ error: 'warranty_claims.errors.save_failed' }, { status: 500 })
362
+ return NextResponse.json({ error: translate('warranty_claims.errors.save_failed', 'Failed to save warranty claim.') }, { status: 500 })
357
363
  }
358
364
  }
359
365
 
@@ -7,6 +7,7 @@ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib
7
7
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
8
  import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
9
9
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
10
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
11
  import { runRouteMutationGuards, type RouteMutationGuardResult } from '@open-mercato/shared/lib/crud/route-mutation-guard'
11
12
  import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
12
13
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -59,24 +60,23 @@ export const metadata = {
59
60
  POST: { requireAuth: true, requireFeatures: ['warranty_claims.claim.manage', 'sales.credit_memos.manage'] },
60
61
  }
61
62
 
62
- function translateKey(key: string): string {
63
- return key
64
- }
65
-
66
63
  function toRecord(value: unknown): Record<string, unknown> {
67
64
  return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
68
65
  }
69
66
 
70
- async function resolveActionContext(req: Request): Promise<ActionRouteContext> {
67
+ async function resolveActionContext(
68
+ req: Request,
69
+ translate: ActionRouteContext['translate'],
70
+ ): Promise<ActionRouteContext> {
71
71
  const container = await createRequestContainer()
72
72
  const auth = await getAuthFromRequest(req)
73
73
  if (!auth || !auth.tenantId) {
74
- throw new CrudHttpError(401, { error: 'warranty_claims.errors.unauthorized' })
74
+ throw new CrudHttpError(401, { error: translate('warranty_claims.errors.unauthorized', 'Unauthorized') })
75
75
  }
76
76
  const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
77
77
  const organizationId = scope?.selectedId ?? auth.orgId ?? null
78
78
  if (!organizationId) {
79
- throw new CrudHttpError(400, { error: 'warranty_claims.errors.organization_required' })
79
+ throw new CrudHttpError(400, { error: translate('warranty_claims.errors.organization_required', 'Organization context is required.') })
80
80
  }
81
81
  return {
82
82
  ctx: {
@@ -89,7 +89,7 @@ async function resolveActionContext(req: Request): Promise<ActionRouteContext> {
89
89
  },
90
90
  tenantId: auth.tenantId,
91
91
  organizationId,
92
- translate: translateKey,
92
+ translate,
93
93
  }
94
94
  }
95
95
 
@@ -115,7 +115,7 @@ async function runGuard(
115
115
  ): Promise<RouteMutationGuardResult> {
116
116
  const userId = context.ctx.auth?.sub
117
117
  if (!userId) {
118
- throw new CrudHttpError(401, { error: 'warranty_claims.errors.unauthorized' })
118
+ throw new CrudHttpError(401, { error: context.translate('warranty_claims.errors.unauthorized', 'Unauthorized') })
119
119
  }
120
120
  return runRouteMutationGuards({
121
121
  container: context.ctx.container,
@@ -135,8 +135,9 @@ async function runGuard(
135
135
  }
136
136
 
137
137
  export async function POST(req: Request) {
138
+ const { translate } = await resolveTranslations()
138
139
  try {
139
- const context = await resolveActionContext(req)
140
+ const context = await resolveActionContext(req, translate)
140
141
  const requestInput = toCreditMemoRequest(toRecord(await readJsonSafe(req, {})))
141
142
  const guarded = await runGuard(req, context, requestInput)
142
143
  if (!guarded.ok) {
@@ -150,7 +151,7 @@ export async function POST(req: Request) {
150
151
  'warranty_claims.claim.create_credit_memo',
151
152
  { input: toCommandInput(guardedInput, context), ctx: context.ctx },
152
153
  )
153
- if (!result) throw new CrudHttpError(400, { error: 'warranty_claims.errors.save_failed' })
154
+ if (!result) throw new CrudHttpError(400, { error: translate('warranty_claims.errors.save_failed', 'Failed to save warranty claim.') })
154
155
  await guarded.runAfterSuccess()
155
156
  return NextResponse.json({
156
157
  creditMemoId: result.creditMemoId,
@@ -165,10 +166,10 @@ export async function POST(req: Request) {
165
166
  return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
166
167
  }
167
168
  if (err instanceof z.ZodError) {
168
- return NextResponse.json({ error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
169
+ return NextResponse.json({ error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
169
170
  }
170
171
  logger.error('warranty_claims.credit-memo.post failed', { err })
171
- return NextResponse.json({ error: 'warranty_claims.errors.save_failed' }, { status: 500 })
172
+ return NextResponse.json({ error: translate('warranty_claims.errors.save_failed', 'Failed to save warranty claim.') }, { status: 500 })
172
173
  }
173
174
  }
174
175
 
@@ -131,7 +131,7 @@ export async function GET(req: Request) {
131
131
  const context = await resolveEventContext(req)
132
132
  const url = new URL(req.url)
133
133
  const query = eventListQuerySchema.parse({ claimId: url.searchParams.get('claimId') ?? undefined })
134
- const claim = await requireScopedClaim(context.em, query.claimId, context.scope)
134
+ const claim = await requireScopedClaim(context.em, query.claimId, context.scope, {}, context.translate('warranty_claims.errors.notFound', 'Claim not found.'))
135
135
  const events = await findWithDecryption(
136
136
  context.em,
137
137
  WarrantyClaimEvent,
@@ -9,7 +9,7 @@ import { runRouteMutationGuards, type RouteMutationGuardResult } from '@open-mer
9
9
  import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
10
10
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
12
- import { getCustomerAuthFromRequest, type CustomerAuthContext } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
12
+ import type { CustomerAuthContext } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
13
13
  import { Attachment, AttachmentPartition } from '@open-mercato/core/modules/attachments/data/entities'
14
14
  import { buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
15
15
  import { StorageDriverFactory } from '@open-mercato/core/modules/attachments/lib/drivers'
@@ -28,6 +28,7 @@ import { WARRANTY_CLAIM_RESOURCE_KIND } from '../../../commands/shared'
28
28
  import { CUSTOMER_VISIBLE_ATTACHMENT_TAG, isCustomerVisibleAttachment } from '../../../lib/attachmentVisibility'
29
29
  import { loadPortalOwnedClaim } from '../../../lib/portalClaimAccess'
30
30
  import { resolvePortalAttachmentUploadService } from '../../../lib/portalAttachmentUpload'
31
+ import { resolveLinkedCustomerAuth } from '../../../lib/portalAuthGuard'
31
32
 
32
33
  const CLAIM_ATTACHMENT_ENTITY_ID = 'warranty_claims:warranty_claim'
33
34
  const logger = createLogger('warranty_claims').child({ route: 'portal-attachments' })
@@ -80,13 +81,9 @@ function toIso(value: Date | string | null | undefined): string | null {
80
81
  }
81
82
 
82
83
  async function resolvePortalContext(req: Request): Promise<PortalContext | Response> {
83
- const auth = await getCustomerAuthFromRequest(req)
84
- if (!auth) {
85
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.unauthorized' }, { status: 401 })
86
- }
87
- if (!auth.customerEntityId) {
88
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.customerAccountNotLinked' }, { status: 403 })
89
- }
84
+ const authOrResponse = await resolveLinkedCustomerAuth(req)
85
+ if (authOrResponse instanceof Response) return authOrResponse
86
+ const auth = authOrResponse
90
87
  const container = await createRequestContainer()
91
88
  const em = container.resolve('em') as EntityManager
92
89
  return {
@@ -240,19 +237,23 @@ async function deletePortalAttachment(context: PortalContext, attachment: Attach
240
237
  }
241
238
  }
242
239
 
243
- async function streamOwnedAttachment(context: PortalContext, attachmentId: string): Promise<Response> {
240
+ async function streamOwnedAttachment(
241
+ context: PortalContext,
242
+ attachmentId: string,
243
+ translate: (key: string, fallback?: string) => string,
244
+ ): Promise<Response> {
244
245
  const owned = await loadOwnedVisibleAttachment(context, attachmentId)
245
246
  if (!owned) {
246
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
247
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
247
248
  }
248
249
  const { attachment } = owned
249
250
  const partition = await context.em.findOne(AttachmentPartition, { code: attachment.partitionCode })
250
251
  if (!partition) {
251
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.load_failed' }, { status: 500 })
252
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.load_failed', 'Failed to load warranty claim data.') }, { status: 500 })
252
253
  }
253
254
  const access = checkAttachmentAccess(attachmentAuth(context), attachment, partition, { requireAuthForPublic: true })
254
255
  if (!access.ok) {
255
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
256
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
256
257
  }
257
258
  const driver = await (await resolveStorageDriverFactory(context)).resolveForPartition(attachment.partitionCode, {
258
259
  tenantId: attachment.tenantId ?? '',
@@ -262,7 +263,7 @@ async function streamOwnedAttachment(context: PortalContext, attachmentId: strin
262
263
  try {
263
264
  buffer = (await driver.read(attachment.partitionCode, attachment.storagePath)).buffer
264
265
  } catch {
265
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
266
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
266
267
  }
267
268
  const headers: Record<string, string> = {
268
269
  'Cache-Control': 'private, max-age=60',
@@ -279,14 +280,15 @@ export async function GET(req: Request) {
279
280
  const contextOrResponse = await resolvePortalContext(req)
280
281
  if (contextOrResponse instanceof Response) return contextOrResponse
281
282
  const context = contextOrResponse
283
+ const { translate } = await resolveTranslations()
282
284
  const url = new URL(req.url)
283
285
  const attachmentId = url.searchParams.get('attachmentId')
284
286
  if (attachmentId) {
285
287
  const attachmentIdParsed = z.string().uuid().safeParse(attachmentId)
286
288
  if (!attachmentIdParsed.success) {
287
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
289
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
288
290
  }
289
- return streamOwnedAttachment(context, attachmentIdParsed.data)
291
+ return streamOwnedAttachment(context, attachmentIdParsed.data, translate)
290
292
  }
291
293
  const parsed = attachmentQuerySchema.safeParse({
292
294
  claimId: url.searchParams.get('claimId') ?? undefined,
@@ -294,11 +296,11 @@ export async function GET(req: Request) {
294
296
  pageSize: url.searchParams.get('pageSize') ?? undefined,
295
297
  })
296
298
  if (!parsed.success) {
297
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
299
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
298
300
  }
299
301
  const claim = await loadOwnedClaim(context, parsed.data.claimId)
300
302
  if (!claim) {
301
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
303
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
302
304
  }
303
305
  const filter = {
304
306
  entityId: CLAIM_ATTACHMENT_ENTITY_ID,
@@ -343,12 +345,12 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
343
345
  const contextOrResponse = await resolvePortalContext(req)
344
346
  if (contextOrResponse instanceof Response) return contextOrResponse
345
347
  const context = contextOrResponse
348
+ const { t, translate } = await resolveTranslations()
346
349
  const contentType = req.headers.get('content-type') || ''
347
350
  if (!contentType.toLowerCase().includes('multipart/form-data')) {
348
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
351
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
349
352
  }
350
353
  if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
351
- const { t } = await resolveTranslations()
352
354
  return NextResponse.json({
353
355
  ok: false,
354
356
  error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
@@ -364,15 +366,15 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
364
366
  ? z.string().uuid().safeParse(form.get('attachmentId') ?? undefined)
365
367
  : null
366
368
  if (!parsed.success || (replacement && !parsedAttachmentId?.success)) {
367
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
369
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
368
370
  }
369
371
  const claim = await loadOwnedClaim(context, parsed.data.claimId)
370
372
  if (!claim) {
371
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
373
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
372
374
  }
373
375
  const file = form.get('file')
374
376
  if (!(file instanceof File)) {
375
- return NextResponse.json({ ok: false, error: 'warranty_claims.portal.detail.fileRequired' }, { status: 400 })
377
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.portal.detail.fileRequired', 'Choose a file before uploading.') }, { status: 400 })
376
378
  }
377
379
  const buffer = Buffer.from(await file.arrayBuffer())
378
380
 
@@ -381,7 +383,7 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
381
383
  ? await loadOwnedVisibleAttachment(context, attachmentId)
382
384
  : null
383
385
  if (replacement && (!replacementTarget || replacementTarget.claim.id !== claim.id)) {
384
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
386
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
385
387
  }
386
388
 
387
389
  const guarded = await runPortalAttachmentGuard(req, context, claim.id, replacement ? 'update' : 'create', {
@@ -397,7 +399,7 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
397
399
 
398
400
  const uploadService = resolvePortalAttachmentUploadService(context.container)
399
401
  if (!uploadService) {
400
- return NextResponse.json({ ok: false, error: 'warranty_claims.portal.detail.attachmentError' }, { status: 503 })
402
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.portal.detail.attachmentError', 'Attachment upload failed.') }, { status: 503 })
401
403
  }
402
404
 
403
405
  let attachment: Attachment
@@ -414,7 +416,6 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
414
416
  })
415
417
  } catch (error) {
416
418
  if (isScopedAttachmentUploadError(error)) {
417
- const { t } = await resolveTranslations()
418
419
  const [key, fallback] = ATTACHMENT_ERROR_TRANSLATIONS[error.code]
419
420
  ?? ['warranty_claims.portal.detail.attachmentError', 'Attachment upload failed.']
420
421
  return NextResponse.json(
@@ -451,16 +452,17 @@ export async function DELETE(req: Request) {
451
452
  const contextOrResponse = await resolvePortalContext(req)
452
453
  if (contextOrResponse instanceof Response) return contextOrResponse
453
454
  const context = contextOrResponse
455
+ const { translate } = await resolveTranslations()
454
456
  const url = new URL(req.url)
455
457
  const parsed = deleteQuerySchema.safeParse({
456
458
  attachmentId: url.searchParams.get('attachmentId') ?? undefined,
457
459
  })
458
460
  if (!parsed.success) {
459
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.invalidInput' }, { status: 400 })
461
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
460
462
  }
461
463
  const owned = await loadOwnedVisibleAttachment(context, parsed.data.attachmentId)
462
464
  if (!owned) {
463
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
465
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
464
466
  }
465
467
  const guarded = await runPortalAttachmentGuard(req, context, owned.claim.id, 'delete', {
466
468
  claimId: owned.claim.id,
@@ -3,10 +3,12 @@ import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
- import { getCustomerAuthFromRequest, type CustomerAuthContext } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
6
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
+ import type { CustomerAuthContext } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
7
8
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
9
  import { WarrantyClaim, WarrantyClaimLine } from '../../../../data/entities'
9
10
  import { loadPortalOwnedClaim } from '../../../../lib/portalClaimAccess'
11
+ import { resolveLinkedCustomerAuth } from '../../../../lib/portalAuthGuard'
10
12
 
11
13
  export const metadata = {
12
14
  GET: { requireAuth: false },
@@ -40,13 +42,9 @@ async function resolveClaimId(ctx: RouteContext): Promise<string | null> {
40
42
  }
41
43
 
42
44
  async function resolvePortalContext(req: Request): Promise<PortalContext | Response> {
43
- const auth = await getCustomerAuthFromRequest(req)
44
- if (!auth) {
45
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.unauthorized' }, { status: 401 })
46
- }
47
- if (!auth.customerEntityId) {
48
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.customerAccountNotLinked' }, { status: 403 })
49
- }
45
+ const authOrResponse = await resolveLinkedCustomerAuth(req)
46
+ if (authOrResponse instanceof Response) return authOrResponse
47
+ const auth = authOrResponse
50
48
  const container = await createRequestContainer()
51
49
  const em = container.resolve('em') as EntityManager
52
50
  return {
@@ -108,9 +106,10 @@ export async function GET(req: Request, ctx: RouteContext) {
108
106
  const contextOrResponse = await resolvePortalContext(req)
109
107
  if (contextOrResponse instanceof Response) return contextOrResponse
110
108
  const context = contextOrResponse
109
+ const { translate } = await resolveTranslations()
111
110
  const claimId = await resolveClaimId(ctx)
112
111
  if (!claimId) {
113
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
112
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
114
113
  }
115
114
  const scope = { tenantId: context.tenantId, organizationId: context.organizationId }
116
115
  const claim = await loadPortalOwnedClaim(
@@ -123,7 +122,7 @@ export async function GET(req: Request, ctx: RouteContext) {
123
122
  claimId,
124
123
  )
125
124
  if (!claim) {
126
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.notFound' }, { status: 404 })
125
+ return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.notFound', 'Claim not found.') }, { status: 404 })
127
126
  }
128
127
  const lines = await findWithDecryption(
129
128
  context.em,
@@ -1,14 +1,14 @@
1
- import { NextResponse } from 'next/server'
2
1
  import { z } from 'zod'
3
2
  import type { EntityManager } from '@mikro-orm/postgresql'
4
3
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
5
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
5
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
7
6
  import { runRouteMutationGuards, type RouteMutationGuardResult } from '@open-mercato/shared/lib/crud/route-mutation-guard'
8
- import { getCustomerAuthFromRequest, type CustomerAuthContext } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
7
+ import type { CustomerAuthContext } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
9
8
  import { WarrantyClaim } from '../../../../data/entities'
10
9
  import { WARRANTY_CLAIM_RESOURCE_KIND } from '../../../../commands/shared'
11
10
  import { loadPortalOwnedClaim } from '../../../../lib/portalClaimAccess'
11
+ import { resolveLinkedCustomerAuth } from '../../../../lib/portalAuthGuard'
12
12
 
13
13
  export type PortalClaimActionRouteContext = { params: Promise<{ id: string }> }
14
14
 
@@ -34,13 +34,9 @@ export async function resolvePortalClaimId(ctx: PortalClaimActionRouteContext):
34
34
  }
35
35
 
36
36
  export async function resolvePortalActionContext(req: Request): Promise<PortalClaimActionContext | Response> {
37
- const auth = await getCustomerAuthFromRequest(req)
38
- if (!auth) {
39
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.unauthorized' }, { status: 401 })
40
- }
41
- if (!auth.customerEntityId) {
42
- return NextResponse.json({ ok: false, error: 'warranty_claims.errors.customerAccountNotLinked' }, { status: 403 })
43
- }
37
+ const authOrResponse = await resolveLinkedCustomerAuth(req)
38
+ if (authOrResponse instanceof Response) return authOrResponse
39
+ const auth = authOrResponse
44
40
  const container = await createRequestContainer()
45
41
  const em = container.resolve('em') as EntityManager
46
42
  const commandAuth: NonNullable<AuthContext> = {