@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/logout.js +14 -6
- package/dist/modules/auth/api/logout.js.map +2 -2
- package/dist/modules/auth/api/session/refresh.js +6 -1
- package/dist/modules/auth/api/session/refresh.js.map +2 -2
- package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
- package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
- package/dist/modules/catalog/ai-tools/types.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/[id]/page.js +5 -2
- package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
- package/dist/modules/currencies/commands/currencies.js +399 -137
- package/dist/modules/currencies/commands/currencies.js.map +2 -2
- package/dist/modules/currencies/lib/seeds.js +2 -0
- package/dist/modules/currencies/lib/seeds.js.map +2 -2
- package/dist/modules/customer_accounts/api/login.js +14 -7
- package/dist/modules/customer_accounts/api/login.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
- package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
- package/dist/modules/customers/commands/companies.js +10 -3
- package/dist/modules/customers/commands/companies.js.map +2 -2
- package/dist/modules/customers/commands/interactionRestoreDetails.js +75 -0
- package/dist/modules/customers/commands/interactionRestoreDetails.js.map +7 -0
- package/dist/modules/customers/commands/people.js +10 -3
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/form-submissions/route.js +16 -1
- package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/runtime-helpers.js +16 -0
- package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
- package/dist/modules/forms/data/validators.js.map +2 -2
- package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
- package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
- package/dist/modules/forms/lib/runtime-principal.js +27 -10
- package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
- package/dist/modules/forms/services/distribution-service.js +35 -13
- package/dist/modules/forms/services/distribution-service.js.map +2 -2
- package/dist/modules/forms/services/submission-service.js +4 -0
- package/dist/modules/forms/services/submission-service.js.map +2 -2
- package/dist/modules/staff/commands/leave-requests.js +54 -24
- package/dist/modules/staff/commands/leave-requests.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
- package/dist/modules/warranty_claims/commands/claims.js +1 -1
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
- package/dist/modules/wms/commands/inventory-actions.js +19 -3
- package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
- package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
- package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
- package/dist/modules/workflows/lib/parallel-handler.js +20 -14
- package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +21 -23
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/logout.ts +15 -6
- package/src/modules/auth/api/session/refresh.ts +6 -1
- package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
- package/src/modules/catalog/ai-tools/types.ts +1 -0
- package/src/modules/currencies/backend/currencies/[id]/page.tsx +5 -1
- package/src/modules/currencies/commands/currencies.ts +475 -168
- package/src/modules/currencies/i18n/de.json +4 -0
- package/src/modules/currencies/i18n/en.json +4 -0
- package/src/modules/currencies/i18n/es.json +4 -0
- package/src/modules/currencies/i18n/ko.json +4 -0
- package/src/modules/currencies/i18n/pl.json +4 -0
- package/src/modules/currencies/lib/seeds.ts +2 -0
- package/src/modules/customer_accounts/api/login.ts +15 -7
- package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
- package/src/modules/customers/commands/companies.ts +16 -4
- package/src/modules/customers/commands/interactionRestoreDetails.ts +110 -0
- package/src/modules/customers/commands/people.ts +16 -4
- package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
- package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
- package/src/modules/forms/api/form-submissions/route.ts +21 -2
- package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
- package/src/modules/forms/api/runtime-helpers.ts +16 -0
- package/src/modules/forms/data/validators.ts +2 -1
- package/src/modules/forms/i18n/de.json +1 -0
- package/src/modules/forms/i18n/en.json +1 -0
- package/src/modules/forms/i18n/es.json +1 -0
- package/src/modules/forms/i18n/ko.json +1 -0
- package/src/modules/forms/i18n/pl.json +1 -0
- package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
- package/src/modules/forms/lib/runtime-principal.ts +35 -11
- package/src/modules/forms/services/distribution-service.ts +35 -13
- package/src/modules/forms/services/submission-service.ts +12 -0
- package/src/modules/staff/commands/leave-requests.ts +54 -23
- package/src/modules/staff/i18n/de.json +2 -0
- package/src/modules/staff/i18n/en.json +2 -0
- package/src/modules/staff/i18n/es.json +2 -0
- package/src/modules/staff/i18n/ko.json +2 -0
- package/src/modules/staff/i18n/pl.json +2 -0
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
- package/src/modules/warranty_claims/commands/claims.ts +1 -1
- package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
- package/src/modules/wms/commands/inventory-actions.ts +30 -10
- package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
- package/src/modules/workflows/lib/parallel-handler.ts +24 -15
- 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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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 (!
|
|
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:
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 {
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1619
|
-
|
|
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,
|