@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
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { NextResponse, type NextRequest } from 'next/server'
6
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
7
  import { SubmissionServiceError, isSubmissionServiceError } from '../services/submission-service'
7
8
  import { DistributionServiceError, isDistributionServiceError } from '../services/distribution-service'
8
9
  import { readEmbedSettings } from '../lib/embed-frame-policy'
@@ -27,6 +28,21 @@ export function mapSubmissionError(error: unknown): NextResponse {
27
28
  return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })
28
29
  }
29
30
 
31
+ export function isPortalSubjectForbiddenError(error: unknown): boolean {
32
+ return isSubmissionServiceError(error) && error.code === 'SUBJECT_FORBIDDEN'
33
+ }
34
+
35
+ export async function portalSubjectForbiddenResponse(): Promise<NextResponse> {
36
+ const { translate } = await resolveTranslations()
37
+ return NextResponse.json(
38
+ {
39
+ error: 'SUBJECT_FORBIDDEN',
40
+ message: translate('forms.errors.subject_forbidden', 'You can only start or submit this form for yourself.'),
41
+ },
42
+ { status: 403 },
43
+ )
44
+ }
45
+
30
46
  export function mapDistributionError(error: unknown): NextResponse {
31
47
  if (isDistributionServiceError(error)) {
32
48
  return NextResponse.json(
@@ -57,7 +57,8 @@ export type RoleIdentifier = z.infer<typeof roleIdentifierSchema>
57
57
  * Submission subject identifier. Polymorphic: subject_type is a free-form
58
58
  * string tag (`patient`, `customer`, `case`, etc.); subject_id MUST be a
59
59
  * UUID. Validation of the (subject_type, subject_id) pair against actual
60
- * domain entities is the responsibility of the calling module.
60
+ * domain entities is the responsibility of the calling module; the portal
61
+ * runtime enforces `lib/portal-subject-policy.ts`.
61
62
  */
62
63
  export const submissionSubjectSchema = z.object({
63
64
  subject_type: z.string().min(1).max(64),
@@ -193,6 +193,7 @@
193
193
  "forms.errors.snapshot_missing": "Snapshot-Eintrag nicht gefunden.",
194
194
  "forms.errors.snapshot_pending": "PDF-Snapshot wird erstellt.",
195
195
  "forms.errors.snapshot_storage_unconfigured": "Snapshot-Speicher ist noch nicht konfiguriert.",
196
+ "forms.errors.subject_forbidden": "Sie können dieses Formular nur für sich selbst starten oder absenden.",
196
197
  "forms.errors.submission_not_found": "Einreichung nicht gefunden.",
197
198
  "forms.errors.tenant_required": "Ein Mandantenkontext ist erforderlich.",
198
199
  "forms.errors.unauthorized": "Nicht autorisiert.",
@@ -193,6 +193,7 @@
193
193
  "forms.errors.snapshot_missing": "Snapshot record not found.",
194
194
  "forms.errors.snapshot_pending": "PDF snapshot is being generated.",
195
195
  "forms.errors.snapshot_storage_unconfigured": "Snapshot storage is not yet configured.",
196
+ "forms.errors.subject_forbidden": "You can only start or submit this form for yourself.",
196
197
  "forms.errors.submission_not_found": "Submission not found.",
197
198
  "forms.errors.tenant_required": "Tenant context is required.",
198
199
  "forms.errors.unauthorized": "Unauthorized.",
@@ -193,6 +193,7 @@
193
193
  "forms.errors.snapshot_missing": "Registro de instantánea no encontrado.",
194
194
  "forms.errors.snapshot_pending": "Se está generando la instantánea en PDF.",
195
195
  "forms.errors.snapshot_storage_unconfigured": "El almacenamiento de instantáneas aún no está configurado.",
196
+ "forms.errors.subject_forbidden": "Solo puede iniciar o enviar este formulario en su propio nombre.",
196
197
  "forms.errors.submission_not_found": "Envío no encontrado.",
197
198
  "forms.errors.tenant_required": "Se requiere el contexto del inquilino.",
198
199
  "forms.errors.unauthorized": "No autorizado.",
@@ -193,6 +193,7 @@
193
193
  "forms.errors.snapshot_missing": "Snapshot record not found.",
194
194
  "forms.errors.snapshot_pending": "PDF snapshot is being generated.",
195
195
  "forms.errors.snapshot_storage_unconfigured": "Snapshot storage is not yet configured.",
196
+ "forms.errors.subject_forbidden": "이 양식은 본인에 대해서만 시작하거나 제출할 수 있습니다.",
196
197
  "forms.errors.submission_not_found": "Submission not found.",
197
198
  "forms.errors.tenant_required": "Tenant context is required.",
198
199
  "forms.errors.unauthorized": "Unauthorized.",
@@ -193,6 +193,7 @@
193
193
  "forms.errors.snapshot_missing": "Nie znaleziono rekordu migawki.",
194
194
  "forms.errors.snapshot_pending": "Migawka PDF jest generowana.",
195
195
  "forms.errors.snapshot_storage_unconfigured": "Magazyn migawek nie został jeszcze skonfigurowany.",
196
+ "forms.errors.subject_forbidden": "Możesz rozpocząć lub wysłać ten formularz tylko we własnym imieniu.",
196
197
  "forms.errors.submission_not_found": "Nie znaleziono zgłoszenia.",
197
198
  "forms.errors.tenant_required": "Wymagany jest kontekst najemcy.",
198
199
  "forms.errors.unauthorized": "Brak uprawnień.",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Portal subject policy (#7224).
3
+ *
4
+ * A portal customer may start a submission, and finalize a submission they
5
+ * started themselves, only for the subject that identifies them: the
6
+ * `customer` subject whose id is their own customer user id (`auth.sub`).
7
+ * Any other pair is refused before anything is persisted, because the
8
+ * subject is what the consent projector attributes signatures to.
9
+ *
10
+ * A submission started by someone else reached the caller through a staff
11
+ * actor assignment (`forms.submissions.manage`), so staff already vouched for
12
+ * that pairing and the submit is not re-checked here. Token (invitation)
13
+ * principals carry a server-derived subject and are out of scope.
14
+ *
15
+ * The start route applies `isPortalSubjectAllowed` before anything is
16
+ * persisted; `SubmissionService.submit()` applies `isPortalSubmitAllowed`
17
+ * inside its locked transaction when the caller is a portal customer.
18
+ */
19
+
20
+ export const PORTAL_SUBJECT_TYPE = 'customer'
21
+
22
+ export type PortalSubject = {
23
+ subjectType: string
24
+ subjectId: string
25
+ }
26
+
27
+ export type PortalSubmissionSubject = PortalSubject & {
28
+ startedBy: string
29
+ }
30
+
31
+ function isSameId(left: string, right: string): boolean {
32
+ return left.toLowerCase() === right.toLowerCase()
33
+ }
34
+
35
+ export function isPortalSubjectAllowed(customerUserId: string, subject: PortalSubject): boolean {
36
+ return subject.subjectType === PORTAL_SUBJECT_TYPE && isSameId(subject.subjectId, customerUserId)
37
+ }
38
+
39
+ export function isPortalSubmitAllowed(customerUserId: string, submission: PortalSubmissionSubject): boolean {
40
+ if (!isSameId(submission.startedBy, customerUserId)) return true
41
+ return isPortalSubjectAllowed(customerUserId, submission)
42
+ }
@@ -9,12 +9,16 @@
9
9
  * the org/tenant are NEVER trusted from the token body — they are re-derived
10
10
  * from the persisted `forms_form_submission` row. The token only authorizes a
11
11
  * single `(submissionId, invitationId, role)` triple; it asserts the token's
12
- * submission matches the route's submission before trusting anything.
12
+ * submission matches the route's submission before trusting anything. The
13
+ * token is also only honoured while its invitation is live (not revoked or
14
+ * expired) and its actor row on the submission is active; the role comes from
15
+ * that actor row, never from the token.
13
16
  */
14
17
 
15
18
  import type { EntityManager } from '@mikro-orm/postgresql'
16
19
  import { getCustomerAuthFromRequest } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
17
- import { FormSubmission, FormSubmissionActor } from '../data/entities'
20
+ import { FormInvitation, FormSubmission, FormSubmissionActor } from '../data/entities'
21
+ import { INVITATION_ACCESS_CLOSED_STATUSES } from '../services/distribution-service'
18
22
  import { verifyAccessToken } from '../services/distribution-token'
19
23
 
20
24
  export type RuntimePrincipal = {
@@ -54,15 +58,35 @@ export async function resolveRuntimePrincipal(
54
58
  id: submissionId,
55
59
  deletedAt: null,
56
60
  })
57
- if (submission) {
58
- return {
59
- source: 'token',
60
- principal: verified.invitationId,
61
- role: verified.role ?? null,
62
- organizationId: submission.organizationId,
63
- tenantId: submission.tenantId,
64
- submissionId,
65
- }
61
+ if (!submission) return null
62
+ const invitation = await em.findOne(FormInvitation, {
63
+ id: verified.invitationId,
64
+ organizationId: submission.organizationId,
65
+ tenantId: submission.tenantId,
66
+ deletedAt: null,
67
+ })
68
+ if (
69
+ !invitation ||
70
+ invitation.submissionId !== submissionId ||
71
+ INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)
72
+ ) {
73
+ return null
74
+ }
75
+ const actor = await em.findOne(FormSubmissionActor, {
76
+ submissionId,
77
+ organizationId: submission.organizationId,
78
+ userId: invitation.id,
79
+ revokedAt: null,
80
+ deletedAt: null,
81
+ })
82
+ if (!actor) return null
83
+ return {
84
+ source: 'token',
85
+ principal: verified.invitationId,
86
+ role: actor.role,
87
+ organizationId: submission.organizationId,
88
+ tenantId: submission.tenantId,
89
+ submissionId,
66
90
  }
67
91
  }
68
92
  // A present-but-invalid bearer token does not fall through to customer
@@ -92,7 +92,9 @@ const noopLogger: StructuredLogger = {
92
92
  error: () => {},
93
93
  }
94
94
 
95
- const INVITATION_TERMINAL_STATUSES: ReadonlySet<string> = new Set(['submitted', 'revoked', 'expired'])
95
+ export const INVITATION_ACCESS_CLOSED_STATUSES: ReadonlySet<string> = new Set(['revoked', 'expired'])
96
+
97
+ const INVITATION_TERMINAL_STATUSES: ReadonlySet<string> = new Set(['submitted', ...INVITATION_ACCESS_CLOSED_STATUSES])
96
98
 
97
99
  export type BeginAnonymousArgs = {
98
100
  distribution: FormDistribution
@@ -378,20 +380,40 @@ export class DistributionService {
378
380
  submissionId: string
379
381
  }): Promise<void> {
380
382
  const em = this.emFactory()
381
- const invitation = await em.findOne(FormInvitation, {
382
- id: args.invitationId,
383
- organizationId: args.organizationId,
384
- tenantId: args.tenantId,
385
- deletedAt: null,
383
+ const marked = await em.transactional(async (trx) => {
384
+ const invitation = await trx.findOne(
385
+ FormInvitation,
386
+ {
387
+ id: args.invitationId,
388
+ organizationId: args.organizationId,
389
+ tenantId: args.tenantId,
390
+ deletedAt: null,
391
+ },
392
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
393
+ )
394
+ if (!invitation) {
395
+ throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)
396
+ }
397
+ if (INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)) {
398
+ this.logger.warn(
399
+ {
400
+ event: 'forms.distribution.invitation_submit_skipped',
401
+ invitationId: invitation.id,
402
+ submissionId: args.submissionId,
403
+ status: invitation.status,
404
+ },
405
+ 'Invitation access closed before submit; status left unchanged.',
406
+ )
407
+ return false
408
+ }
409
+ invitation.status = 'submitted'
410
+ invitation.submittedAt = this.now()
411
+ await trx.flush()
412
+ return true
386
413
  })
387
- if (!invitation) {
388
- throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)
389
- }
390
- invitation.status = 'submitted'
391
- invitation.submittedAt = this.now()
392
- await em.flush()
414
+ if (!marked) return
393
415
  await this.safeEmit('forms.invitation.submitted', {
394
- invitationId: invitation.id,
416
+ invitationId: args.invitationId,
395
417
  submissionId: args.submissionId,
396
418
  })
397
419
  }
@@ -39,6 +39,7 @@ import {
39
39
  type VisibilitySchemaNode,
40
40
  } from './visibility-resolver'
41
41
  import { buildTamperingMarker, type StructuredLogger } from '../lib/log-redaction'
42
+ import { isPortalSubmitAllowed } from '../lib/portal-subject-policy'
42
43
  import { formsEventPayloadSchemas } from '../events-payloads'
43
44
  import { validateFieldValue } from './field-validation-service'
44
45
  import type { FieldNode } from '../backend/forms/[id]/studio/schema-helpers'
@@ -74,6 +75,7 @@ export type SubmissionServiceErrorCode =
74
75
  | 'INVALID_ROLE'
75
76
  | 'FORM_INACTIVE'
76
77
  | 'FORM_VERSION_NOT_PUBLISHED'
78
+ | 'SUBJECT_FORBIDDEN'
77
79
 
78
80
  /**
79
81
  * `Symbol.for` so the brand survives module duplication across bundle
@@ -144,6 +146,13 @@ export type SubmitArgs = Scope & {
144
146
  baseRevisionId: string
145
147
  submittedBy: string
146
148
  submitMetadata?: Record<string, unknown> | null
149
+ /**
150
+ * Set when `submittedBy` is a signed-in portal customer. A submission that
151
+ * customer started themselves must then carry their own `customer` subject
152
+ * (`lib/portal-subject-policy.ts`), otherwise 403 SUBJECT_FORBIDDEN. Left
153
+ * unset for invitation (token) principals, whose subject is server-derived.
154
+ */
155
+ submittedByPortalCustomer?: boolean
147
156
  }
148
157
 
149
158
  export type ReopenArgs = Scope & {
@@ -748,6 +757,9 @@ export class SubmissionService {
748
757
  if (!submission) {
749
758
  throw new SubmissionServiceError('NOT_FOUND', 'Submission not found.', 404)
750
759
  }
760
+ if (args.submittedByPortalCustomer && !isPortalSubmitAllowed(args.submittedBy, submission)) {
761
+ throw new SubmissionServiceError('SUBJECT_FORBIDDEN', 'Submission subject is not the caller\'s own customer subject.', 403)
762
+ }
751
763
  if (submission.status === 'submitted') {
752
764
  throw new SubmissionServiceError('INVALID_STATUS', 'Submission already submitted.', 422)
753
765
  }
@@ -12,7 +12,7 @@ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
12
12
  import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
13
13
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
14
  import { PlannerAvailabilityRule } from '@open-mercato/core/modules/planner/data/entities'
15
- import { StaffLeaveRequest, type StaffLeaveRequestStatus } from '../data/entities'
15
+ import { StaffLeaveRequest, StaffTeamMember, type StaffLeaveRequestStatus } from '../data/entities'
16
16
  import {
17
17
  staffLeaveRequestCreateSchema,
18
18
  staffLeaveRequestDecisionSchema,
@@ -210,6 +210,36 @@ function ensurePendingStatus(request: StaffLeaveRequest): void {
210
210
  }
211
211
  }
212
212
 
213
+ async function ensureUndoablePending(request: StaffLeaveRequest): Promise<void> {
214
+ if (request.status === 'pending') return
215
+ const { translate } = await resolveTranslations()
216
+ throw new CrudHttpError(409, {
217
+ error: translate(
218
+ 'staff.leaveRequests.errors.undoAfterDecision',
219
+ 'This leave request has already been approved or rejected and can no longer be undone. Undo the decision first.',
220
+ ),
221
+ })
222
+ }
223
+
224
+ async function requireUndoMember(em: EntityManager, snapshot: LeaveRequestSnapshot): Promise<StaffTeamMember> {
225
+ const scope = explicitStaffCommandScope(snapshot.tenantId, snapshot.organizationId)
226
+ const member = await findOneWithDecryption(
227
+ em,
228
+ StaffTeamMember,
229
+ applyScopeToWhere<StaffTeamMember>({ id: snapshot.memberId, deletedAt: null }, scope),
230
+ undefined,
231
+ scopeForDecryption(scope),
232
+ )
233
+ if (member) return member
234
+ const { translate } = await resolveTranslations()
235
+ throw new CrudHttpError(409, {
236
+ error: translate(
237
+ 'staff.leaveRequests.errors.undoMemberMissing',
238
+ 'The original team member of this leave request no longer exists, so the change cannot be undone.',
239
+ ),
240
+ })
241
+ }
242
+
213
243
  async function createUnavailabilityRules(params: {
214
244
  em: EntityManager
215
245
  tenantId: string
@@ -388,25 +418,25 @@ const createLeaveRequestCommand: CommandHandler<StaffLeaveRequestCreateInput, {
388
418
  if (!after) return
389
419
  const em = (ctx.container.resolve('em') as EntityManager).fork()
390
420
  const request = await em.findOne(StaffLeaveRequest, scopedStaffSnapshotWhere(after.id, staffSnapshotScopeFromSnapshot(after)))
391
- if (request) {
392
- request.deletedAt = new Date()
393
- request.updatedAt = new Date()
394
- await em.flush()
421
+ if (!request || request.deletedAt) return
422
+ await ensureUndoablePending(request)
423
+ request.deletedAt = new Date()
424
+ request.updatedAt = new Date()
425
+ await em.flush()
395
426
 
396
- const de = (ctx.container.resolve('dataEngine') as DataEngine)
397
- await emitCrudUndoSideEffects({
398
- dataEngine: de,
399
- action: 'deleted',
400
- entity: request,
401
- identifiers: {
402
- id: request.id,
403
- organizationId: request.organizationId,
404
- tenantId: request.tenantId,
405
- },
406
- events: staffLeaveRequestCrudEvents,
427
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
428
+ await emitCrudUndoSideEffects({
429
+ dataEngine: de,
430
+ action: 'deleted',
431
+ entity: request,
432
+ identifiers: {
433
+ id: request.id,
434
+ organizationId: request.organizationId,
435
+ tenantId: request.tenantId,
436
+ },
437
+ events: staffLeaveRequestCrudEvents,
407
438
  indexer: leaveRequestCrudIndexer,
408
- })
409
- }
439
+ })
410
440
  },
411
441
  redo: makeCreateRedo<StaffLeaveRequest, LeaveRequestSnapshot, StaffLeaveRequestCreateInput, { requestId: string }>({
412
442
  entityClass: StaffLeaveRequest,
@@ -517,17 +547,18 @@ const updateLeaveRequestCommand: CommandHandler<StaffLeaveRequestUpdateInput, {
517
547
  if (!before || !after) return
518
548
  const em = (ctx.container.resolve('em') as EntityManager).fork()
519
549
  const request = await em.findOne(StaffLeaveRequest, scopedStaffSnapshotWhere(after.id, staffSnapshotScopeFromSnapshot(after)))
520
- if (!request) return
550
+ if (!request || request.deletedAt) return
551
+ await ensureUndoablePending(request)
552
+ const currentMemberId = typeof request.member === 'string' ? request.member : request.member.id
553
+ if (before.memberId !== currentMemberId) {
554
+ request.member = await requireUndoMember(em, before)
555
+ }
521
556
  request.startDate = new Date(before.startDate)
522
557
  request.endDate = new Date(before.endDate)
523
558
  request.timezone = before.timezone
524
- request.status = before.status
525
559
  request.unavailabilityReasonEntryId = before.unavailabilityReasonEntryId
526
560
  request.unavailabilityReasonValue = before.unavailabilityReasonValue
527
561
  request.note = before.note
528
- request.decisionComment = before.decisionComment
529
- request.decidedByUserId = before.decidedByUserId
530
- request.decidedAt = before.decidedAt ? new Date(before.decidedAt) : null
531
562
  request.updatedAt = new Date()
532
563
  await em.flush()
533
564
 
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "Profil konnte nicht geladen werden.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Bitte zuerst dein Profil erstellen.",
271
271
  "staff.leaveRequests.errors.reject": "Urlaubsantrag konnte nicht abgelehnt werden.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "Dieser Urlaubsantrag wurde bereits genehmigt oder abgelehnt und kann nicht mehr rückgängig gemacht werden. Mache zuerst die Entscheidung rückgängig.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "Das ursprüngliche Teammitglied dieses Urlaubsantrags existiert nicht mehr, daher kann die Änderung nicht rückgängig gemacht werden.",
272
274
  "staff.leaveRequests.form.actions.create": "Erstellen",
273
275
  "staff.leaveRequests.form.actions.save": "Speichern",
274
276
  "staff.leaveRequests.form.createTitle": "Urlaubsantrag erstellen",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "Failed to load your profile.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Create your profile first.",
271
271
  "staff.leaveRequests.errors.reject": "Failed to reject leave request.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "This leave request has already been approved or rejected and can no longer be undone. Undo the decision first.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "The original team member of this leave request no longer exists, so the change cannot be undone.",
272
274
  "staff.leaveRequests.form.actions.create": "Create",
273
275
  "staff.leaveRequests.form.actions.save": "Save",
274
276
  "staff.leaveRequests.form.createTitle": "Create leave request",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "No se pudo cargar tu perfil.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Crea tu perfil primero.",
271
271
  "staff.leaveRequests.errors.reject": "No se pudo rechazar la solicitud de ausencia.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "Esta solicitud de ausencia ya fue aprobada o rechazada y ya no se puede deshacer. Deshaz primero la decisión.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "El miembro del equipo original de esta solicitud de ausencia ya no existe, por lo que el cambio no se puede deshacer.",
272
274
  "staff.leaveRequests.form.actions.create": "Crear",
273
275
  "staff.leaveRequests.form.actions.save": "Guardar",
274
276
  "staff.leaveRequests.form.createTitle": "Crear solicitud de ausencia",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "프로필을 불러오지 못했습니다.",
270
270
  "staff.leaveRequests.errors.profileRequired": "먼저 프로필을 생성하세요.",
271
271
  "staff.leaveRequests.errors.reject": "휴가 신청을 반려하지 못했습니다.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "이 휴가 신청은 이미 승인 또는 반려되어 더 이상 실행 취소할 수 없습니다. 먼저 결정을 실행 취소하세요.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "이 휴가 신청의 원래 팀원이 더 이상 존재하지 않아 변경 사항을 실행 취소할 수 없습니다.",
272
274
  "staff.leaveRequests.form.actions.create": "생성",
273
275
  "staff.leaveRequests.form.actions.save": "저장",
274
276
  "staff.leaveRequests.form.createTitle": "휴가 신청 생성",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "Nie udało się załadować profilu.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Najpierw utwórz swój profil.",
271
271
  "staff.leaveRequests.errors.reject": "Nie udało się odrzucić wniosku urlopowego.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "Ten wniosek urlopowy został już zatwierdzony lub odrzucony i nie można go już cofnąć. Najpierw cofnij decyzję.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "Pierwotny członek zespołu tego wniosku urlopowego już nie istnieje, więc nie można cofnąć zmiany.",
272
274
  "staff.leaveRequests.form.actions.create": "Utwórz",
273
275
  "staff.leaveRequests.form.actions.save": "Zapisz",
274
276
  "staff.leaveRequests.form.createTitle": "Utwórz wniosek urlopowy",
@@ -17,7 +17,11 @@ import { checkAttachmentAccess } from '@open-mercato/core/modules/attachments/li
17
17
  import { attachmentCrudEvents, attachmentCrudIndexer } from '@open-mercato/core/modules/attachments/lib/crud'
18
18
  import { readAttachmentMetadata } from '@open-mercato/core/modules/attachments/lib/metadata'
19
19
  import { clearAttachmentThumbnailCache } from '@open-mercato/core/modules/attachments/lib/thumbnailCache'
20
- import { isMultipartRequestWithinUploadLimit } from '@open-mercato/core/modules/attachments/lib/upload-limits'
20
+ import {
21
+ isMultipartUploadLimitError,
22
+ parseMultipartFormDataWithinUploadLimit,
23
+ resolveDefaultAttachmentMaxUploadBytes,
24
+ } from '@open-mercato/core/modules/attachments/lib/upload-limits'
21
25
  import {
22
26
  isScopedAttachmentUploadError,
23
27
  type ScopedAttachmentUploadErrorCode,
@@ -341,6 +345,14 @@ export async function GET(req: Request) {
341
345
  })
342
346
  }
343
347
 
348
+ function uploadTooLargeResponse(t: Awaited<ReturnType<typeof resolveTranslations>>['t']): Response {
349
+ return NextResponse.json({
350
+ ok: false,
351
+ error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
352
+ code: 'max_upload_size',
353
+ }, { status: 413 })
354
+ }
355
+
344
356
  async function handleUpload(req: Request, replacement: boolean): Promise<Response> {
345
357
  const contextOrResponse = await resolvePortalContext(req)
346
358
  if (contextOrResponse instanceof Response) return contextOrResponse
@@ -350,14 +362,13 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
350
362
  if (!contentType.toLowerCase().includes('multipart/form-data')) {
351
363
  return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
352
364
  }
353
- if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
354
- return NextResponse.json({
355
- ok: false,
356
- error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
357
- }, { status: 413 })
365
+ let form: FormData
366
+ try {
367
+ form = await parseMultipartFormDataWithinUploadLimit(req)
368
+ } catch (error) {
369
+ if (isMultipartUploadLimitError(error)) return uploadTooLargeResponse(t)
370
+ throw error
358
371
  }
359
-
360
- const form = await req.formData()
361
372
  const parsed = uploadBodySchema.safeParse({
362
373
  claimId: form.get('claimId') ?? undefined,
363
374
  file: form.get('file') instanceof File ? (form.get('file') as File).name : '',
@@ -376,6 +387,7 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
376
387
  if (!(file instanceof File)) {
377
388
  return NextResponse.json({ ok: false, error: translate('warranty_claims.portal.detail.fileRequired', 'Choose a file before uploading.') }, { status: 400 })
378
389
  }
390
+ if (file.size > resolveDefaultAttachmentMaxUploadBytes()) return uploadTooLargeResponse(t)
379
391
  const buffer = Buffer.from(await file.arrayBuffer())
380
392
 
381
393
  const attachmentId = replacement && parsedAttachmentId?.success ? parsedAttachmentId.data : null
@@ -269,7 +269,7 @@ function hasOwn(input: object, key: string): boolean {
269
269
  }
270
270
 
271
271
  function resolveScope(ctx: CommandRuntimeContext, input: ScopeInput): WarrantyClaimScope {
272
- const tenantId = input.tenantId ?? ctx.auth?.tenantId ?? null
272
+ const tenantId = input.tenantId ?? ctx.auth?.tenantId ?? ctx.organizationScope?.tenantId ?? null
273
273
  const organizationId = input.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
274
274
  if (!tenantId) throw new CrudHttpError(400, { error: '[internal] tenant scope required for warranty claim command' })
275
275
  if (!organizationId) throw new CrudHttpError(400, { error: '[internal] organization scope required for warranty claim command' })
@@ -7,7 +7,6 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
7
7
  import type { TransitionClaimInput } from '../data/validators'
8
8
 
9
9
  const logger = createLogger('warranty_claims')
10
- const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000'
11
10
 
12
11
  export const metadata = {
13
12
  event: 'shipping_carriers.shipment.delivered',
@@ -182,12 +181,13 @@ export default async function handle(payload: unknown, ctx: SubscriberContext):
182
181
 
183
182
  const commandCtx: CommandRuntimeContext = {
184
183
  container: container as unknown as AwilixContainer,
185
- auth: {
186
- sub: SYSTEM_USER_ID,
184
+ auth: null,
185
+ organizationScope: {
186
+ selectedId: organizationId,
187
+ filterIds: null,
188
+ allowedIds: null,
187
189
  tenantId,
188
- orgId: organizationId,
189
190
  },
190
- organizationScope: null,
191
191
  selectedOrganizationId: organizationId,
192
192
  organizationIds: [organizationId],
193
193
  systemActor: true,
@@ -734,19 +734,37 @@ async function findReservationBucketBalance(
734
734
  })
735
735
  }
736
736
 
737
+ type BalanceBucketInput = {
738
+ warehouseId: string
739
+ locationId: string
740
+ catalogVariantId: string
741
+ lotId?: string
742
+ serialNumber?: string
743
+ }
744
+
745
+ const BALANCE_BUCKET_CREATE_LOCK_SQL = 'select pg_advisory_xact_lock(hashtextextended(?, 0))'
746
+
747
+ function balanceBucketCreateLockKey(input: BalanceBucketInput): string {
748
+ return [
749
+ 'wms:inventory-balance-bucket',
750
+ input.warehouseId.toLowerCase(),
751
+ input.locationId.toLowerCase(),
752
+ input.catalogVariantId.toLowerCase(),
753
+ input.lotId?.toLowerCase() ?? '',
754
+ input.serialNumber ?? '',
755
+ ].join(':')
756
+ }
757
+
737
758
  async function upsertBalanceBucket(
738
759
  em: EntityManager,
739
760
  scope: Scope,
740
- input: {
741
- warehouseId: string
742
- locationId: string
743
- catalogVariantId: string
744
- lotId?: string
745
- serialNumber?: string
746
- },
761
+ input: BalanceBucketInput,
747
762
  ): Promise<{ balance: InventoryBalance; created: boolean }> {
748
763
  const existing = await findExactBalanceForUpdate(em, scope, input)
749
764
  if (existing) return { balance: existing, created: false }
765
+ await em.execute(BALANCE_BUCKET_CREATE_LOCK_SQL, [balanceBucketCreateLockKey(input)])
766
+ const createdConcurrently = await findExactBalanceForUpdate(em, scope, input)
767
+ if (createdConcurrently) return { balance: createdConcurrently, created: false }
750
768
  const balance = em.create(InventoryBalance, {
751
769
  organizationId: scope.organizationId,
752
770
  tenantId: scope.tenantId,
@@ -1608,15 +1626,17 @@ const moveInventoryCommand: CommandHandler<InventoryMoveInput, { movementId: str
1608
1626
  if (sourceWarehouseId !== input.warehouseId || targetWarehouseId !== input.warehouseId) {
1609
1627
  throw new CrudHttpError(422, { error: 'invalid_location' })
1610
1628
  }
1611
- const sourceResult = await upsertBalanceBucket(trx, scope, {
1629
+ const sourceBalance = await findExactBalanceForUpdate(trx, scope, {
1612
1630
  warehouseId: input.warehouseId,
1613
1631
  locationId: input.fromLocationId,
1614
1632
  catalogVariantId: input.catalogVariantId,
1615
1633
  lotId: input.lotId,
1616
1634
  serialNumber: input.serialNumber,
1617
1635
  })
1618
- const sourceBalance = sourceResult.balance
1619
- const sourceBalanceAction = sourceResult.created ? ('created' as const) : ('updated' as const)
1636
+ if (!sourceBalance) {
1637
+ throw new CrudHttpError(409, { error: 'insufficient_stock' })
1638
+ }
1639
+ const sourceBalanceAction = 'updated' as const
1620
1640
  const targetResult = await upsertBalanceBucket(trx, scope, {
1621
1641
  warehouseId: input.warehouseId,
1622
1642
  locationId: input.toLocationId,