@open-mercato/core 0.6.7-develop.6631.1.9d64b49ddc → 0.6.7-develop.6645.1.52f0b37813
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/dist/modules/attachments/components/AttachmentLibrary.js +13 -2
- package/dist/modules/attachments/components/AttachmentLibrary.js.map +2 -2
- package/dist/modules/catalog/di.js +1 -1
- package/dist/modules/catalog/di.js.map +2 -2
- package/dist/modules/dashboards/lib/widgets.js +5 -3
- package/dist/modules/dashboards/lib/widgets.js.map +2 -2
- package/dist/modules/directory/api/get/organizations/lookup.js +51 -3
- package/dist/modules/directory/api/get/organizations/lookup.js.map +2 -2
- package/dist/modules/directory/api/get/tenants/lookup.js +31 -1
- package/dist/modules/directory/api/get/tenants/lookup.js.map +2 -2
- package/dist/modules/notifications/di.js +1 -1
- package/dist/modules/notifications/di.js.map +2 -2
- package/dist/modules/planner/commands/availability-rule-sets.js +37 -15
- package/dist/modules/planner/commands/availability-rule-sets.js.map +2 -2
- package/dist/modules/planner/commands/availability.js +37 -11
- package/dist/modules/planner/commands/availability.js.map +2 -2
- package/dist/modules/planner/commands/shared.js +34 -1
- package/dist/modules/planner/commands/shared.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/components/AttachmentLibrary.tsx +9 -3
- package/src/modules/catalog/di.ts +3 -1
- package/src/modules/dashboards/lib/widgets.ts +5 -3
- package/src/modules/directory/api/get/organizations/lookup.ts +87 -2
- package/src/modules/directory/api/get/tenants/lookup.ts +46 -0
- package/src/modules/notifications/di.ts +1 -1
- package/src/modules/planner/commands/availability-rule-sets.ts +38 -14
- package/src/modules/planner/commands/availability.ts +44 -11
- package/src/modules/planner/commands/shared.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6645.1.52f0b37813",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -254,16 +254,16 @@
|
|
|
254
254
|
"zod": "^4.4.3"
|
|
255
255
|
},
|
|
256
256
|
"peerDependencies": {
|
|
257
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
258
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
259
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
257
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6645.1.52f0b37813",
|
|
258
|
+
"@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
|
|
259
|
+
"@open-mercato/ui": "0.6.7-develop.6645.1.52f0b37813",
|
|
260
260
|
"react": "^19.0.0",
|
|
261
261
|
"react-dom": "^19.0.0"
|
|
262
262
|
},
|
|
263
263
|
"devDependencies": {
|
|
264
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
265
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
266
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
264
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6645.1.52f0b37813",
|
|
265
|
+
"@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
|
|
266
|
+
"@open-mercato/ui": "0.6.7-develop.6645.1.52f0b37813",
|
|
267
267
|
"@testing-library/dom": "^10.4.1",
|
|
268
268
|
"@testing-library/jest-dom": "^6.9.1",
|
|
269
269
|
"@testing-library/react": "^16.3.1",
|
|
@@ -199,7 +199,7 @@ type AssignmentsEditorProps = {
|
|
|
199
199
|
disabled?: boolean
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
function AttachmentAssignmentsEditor({ value, onChange, labels, disabled }: AssignmentsEditorProps) {
|
|
202
|
+
export function AttachmentAssignmentsEditor({ value, onChange, labels, disabled }: AssignmentsEditorProps) {
|
|
203
203
|
const handleChange = React.useCallback(
|
|
204
204
|
(index: number, patch: Partial<AssignmentDraft>) => {
|
|
205
205
|
onChange(value.map((entry, idx) => (idx === index ? { ...entry, ...patch } : entry)))
|
|
@@ -229,7 +229,7 @@ function AttachmentAssignmentsEditor({ value, onChange, labels, disabled }: Assi
|
|
|
229
229
|
<div className="text-xs text-muted-foreground">No assignments yet.</div>
|
|
230
230
|
) : (
|
|
231
231
|
value.map((entry, index) => (
|
|
232
|
-
<div key={
|
|
232
|
+
<div key={index} className="rounded border p-3 space-y-2">
|
|
233
233
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
234
234
|
<div className="space-y-1">
|
|
235
235
|
<label className="text-xs font-medium">{labels.type}</label>
|
|
@@ -916,6 +916,7 @@ export function AttachmentLibrary() {
|
|
|
916
916
|
className="text-sm text-blue-600 underline"
|
|
917
917
|
target="_blank"
|
|
918
918
|
rel="noreferrer"
|
|
919
|
+
onClick={(event) => event.stopPropagation()}
|
|
919
920
|
>
|
|
920
921
|
{content}
|
|
921
922
|
</a>
|
|
@@ -961,7 +962,12 @@ export function AttachmentLibrary() {
|
|
|
961
962
|
const absolute = resolveAbsoluteUrl(downloadPath)
|
|
962
963
|
return (
|
|
963
964
|
<Button variant="ghost" size="icon" asChild>
|
|
964
|
-
<a
|
|
965
|
+
<a
|
|
966
|
+
href={absolute}
|
|
967
|
+
download
|
|
968
|
+
aria-label={t('attachments.library.table.download', 'Download')}
|
|
969
|
+
onClick={(event) => event.stopPropagation()}
|
|
970
|
+
>
|
|
965
971
|
<Download className="h-4 w-4" />
|
|
966
972
|
</a>
|
|
967
973
|
</Button>
|
|
@@ -12,7 +12,9 @@ export function register(container: AppContainer) {
|
|
|
12
12
|
container.register({
|
|
13
13
|
catalogPricingService: asFunction(({ eventBus }: AppCradle) => {
|
|
14
14
|
return new DefaultCatalogPricingService(eventBus ?? null)
|
|
15
|
-
})
|
|
15
|
+
})
|
|
16
|
+
.singleton()
|
|
17
|
+
.proxy(),
|
|
16
18
|
CatalogProduct: asValue(CatalogProduct),
|
|
17
19
|
CatalogProductPrice: asValue(CatalogProductPrice),
|
|
18
20
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Module, ModuleDashboardWidgetEntry } from '@open-mercato/shared/modules/registry'
|
|
2
2
|
import type { DashboardWidgetMetadata, DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
3
|
+
import { applyDashboardWidgetOverridesToEntries } from '@open-mercato/shared/modules/overrides'
|
|
3
4
|
import { getModules } from '@open-mercato/shared/lib/i18n/server'
|
|
4
5
|
|
|
5
6
|
type LoadedWidgetModule = DashboardWidgetModule<any> & { metadata: DashboardWidgetMetadata }
|
|
@@ -20,13 +21,14 @@ async function loadWidgetEntries(): Promise<WidgetEntry[]> {
|
|
|
20
21
|
if (!widgetEntriesPromise) {
|
|
21
22
|
widgetEntriesPromise = Promise.resolve().then(() => {
|
|
22
23
|
const list = getModules() as Module[]
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
return
|
|
24
|
+
const entries = list.flatMap((mod) => {
|
|
25
|
+
const moduleEntries = mod.dashboardWidgets ?? []
|
|
26
|
+
return moduleEntries.map((entry) => ({
|
|
26
27
|
...entry,
|
|
27
28
|
moduleId: mod.id,
|
|
28
29
|
}))
|
|
29
30
|
})
|
|
31
|
+
return applyDashboardWidgetOverridesToEntries(entries) as WidgetEntry[]
|
|
30
32
|
})
|
|
31
33
|
}
|
|
32
34
|
return widgetEntriesPromise
|
|
@@ -2,8 +2,13 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
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
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
6
|
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
6
7
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
8
|
+
import { getCachedRateLimiterService } from '@open-mercato/core/bootstrap'
|
|
9
|
+
import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
|
|
10
|
+
import { checkRateLimit, getClientIp, rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
11
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
12
|
|
|
8
13
|
export const metadata = {
|
|
9
14
|
path: '/directory/organizations/lookup',
|
|
@@ -16,7 +21,76 @@ const orgLookupQuerySchema = z.object({
|
|
|
16
21
|
slug: z.string().min(1).max(150),
|
|
17
22
|
})
|
|
18
23
|
|
|
24
|
+
// Unauthenticated pre-auth portal resolver: an anonymous caller can probe any
|
|
25
|
+
// slug, so cap probes per IP to keep slug enumeration bounded and noisy.
|
|
26
|
+
//
|
|
27
|
+
// The cap matches the sibling tenant lookup (60/min/IP) because the legitimate
|
|
28
|
+
// call pattern is the same: useTenantContext resolves the org on every portal
|
|
29
|
+
// page mount without caching, and a whole workforce behind one NAT egress IP —
|
|
30
|
+
// or one reverse proxy — shares this budget. A tripped lookup renders as a
|
|
31
|
+
// missing organization, so the cap must clear real portal traffic by a wide
|
|
32
|
+
// margin. Tune per deployment with RATE_LIMIT_DIRECTORY_ORG_LOOKUP_POINTS /
|
|
33
|
+
// _DURATION / _BLOCK_DURATION.
|
|
34
|
+
const orgLookupIpRateLimitConfig = readEndpointRateLimitConfig('DIRECTORY_ORG_LOOKUP', {
|
|
35
|
+
points: 60,
|
|
36
|
+
duration: 60,
|
|
37
|
+
blockDuration: 60,
|
|
38
|
+
keyPrefix: 'directory-org-lookup',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Structural contract for the OPTIONAL `domainMappingService` owned by
|
|
42
|
+
// `customer_accounts`. Declared locally (never imported) because
|
|
43
|
+
// `customer_accounts` already depends on `directory` — importing it back would
|
|
44
|
+
// invert the dependency and break this module's isomorphism.
|
|
45
|
+
type DomainTenantResolver = {
|
|
46
|
+
resolveByHostname(hostname: string): Promise<{ tenantId: string; status: string } | null>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A branded (custom-domain) portal host already implies a tenant, so bound the
|
|
50
|
+
// slug lookup to it. Returns null on the platform domain, on an unmapped or
|
|
51
|
+
// inactive host, and when the peer module is absent — those keep the global
|
|
52
|
+
// resolution the platform-domain portal bootstrap depends on. The Host header is
|
|
53
|
+
// caller-controlled, so this is containment for legitimate branded traffic, not
|
|
54
|
+
// a trust boundary; the rate limit above is what bounds enumeration.
|
|
55
|
+
async function resolveTenantFromHost(container: AppContainer, req: Request): Promise<string | null> {
|
|
56
|
+
const host = req.headers.get('host')
|
|
57
|
+
if (!host) return null
|
|
58
|
+
try {
|
|
59
|
+
const resolver = container.resolve('domainMappingService') as DomainTenantResolver
|
|
60
|
+
const mapping = await resolver.resolveByHostname(host)
|
|
61
|
+
if (!mapping || mapping.status !== 'active') return null
|
|
62
|
+
return mapping.tenantId
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fail-open, like auth's checkAuthRateLimit: this resolver sits on the unauthenticated
|
|
69
|
+
// portal bootstrap path, so a limiter outage must degrade to unlimited lookups rather
|
|
70
|
+
// than 500 the portal.
|
|
71
|
+
async function enforceOrgLookupRateLimit(req: Request): Promise<NextResponse | null> {
|
|
72
|
+
try {
|
|
73
|
+
const rateLimiterService = getCachedRateLimiterService()
|
|
74
|
+
if (!rateLimiterService) return null
|
|
75
|
+
const clientIp = getClientIp(req, rateLimiterService.trustProxyDepth)
|
|
76
|
+
if (!clientIp) return null
|
|
77
|
+
const { translate } = await resolveTranslations()
|
|
78
|
+
return await checkRateLimit(
|
|
79
|
+
rateLimiterService,
|
|
80
|
+
orgLookupIpRateLimitConfig,
|
|
81
|
+
clientIp,
|
|
82
|
+
translate('api.errors.rateLimit', 'Too many requests. Please try again later.'),
|
|
83
|
+
)
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
19
89
|
export async function GET(req: Request) {
|
|
90
|
+
// Consume the limit before validation or DB work, so junk input cannot bypass it.
|
|
91
|
+
const rateLimitResponse = await enforceOrgLookupRateLimit(req)
|
|
92
|
+
if (rateLimitResponse) return rateLimitResponse
|
|
93
|
+
|
|
20
94
|
const url = new URL(req.url)
|
|
21
95
|
const slug = url.searchParams.get('slug') || ''
|
|
22
96
|
const parsed = orgLookupQuerySchema.safeParse({ slug })
|
|
@@ -26,9 +100,18 @@ export async function GET(req: Request) {
|
|
|
26
100
|
|
|
27
101
|
const container = await createRequestContainer()
|
|
28
102
|
const em = container.resolve('em') as EntityManager
|
|
103
|
+
const tenantId = await resolveTenantFromHost(container, req)
|
|
104
|
+
// `slug` is unique per (tenant, slug), not globally, so an unscoped match can
|
|
105
|
+
// hit several tenants' rows. Order deterministically so an unscoped collision
|
|
106
|
+
// always resolves to the same organization instead of an arbitrary one.
|
|
29
107
|
const organization = await em.findOne(
|
|
30
108
|
Organization,
|
|
31
|
-
{
|
|
109
|
+
{
|
|
110
|
+
slug: parsed.data.slug,
|
|
111
|
+
deletedAt: null,
|
|
112
|
+
...(tenantId ? { tenant: tenantId } : {}),
|
|
113
|
+
},
|
|
114
|
+
{ orderBy: { createdAt: 'ASC' } },
|
|
32
115
|
)
|
|
33
116
|
if (!organization) {
|
|
34
117
|
return NextResponse.json({ ok: false, error: 'Organization not found.' }, { status: 404 })
|
|
@@ -61,7 +144,8 @@ const orgLookupErrorSchema = z.object({
|
|
|
61
144
|
|
|
62
145
|
const orgLookupDoc: OpenApiMethodDoc = {
|
|
63
146
|
summary: 'Public organization lookup by slug',
|
|
64
|
-
description:
|
|
147
|
+
description:
|
|
148
|
+
'Resolves organization metadata for portal flows. No authentication required, so the endpoint is rate limited per IP. On a custom-domain host the lookup is scoped to that host\'s tenant.',
|
|
65
149
|
tags: [lookupTag],
|
|
66
150
|
query: orgLookupQuerySchema,
|
|
67
151
|
responses: [
|
|
@@ -70,6 +154,7 @@ const orgLookupDoc: OpenApiMethodDoc = {
|
|
|
70
154
|
errors: [
|
|
71
155
|
{ status: 400, description: 'Invalid slug', schema: orgLookupErrorSchema },
|
|
72
156
|
{ status: 404, description: 'Organization not found', schema: orgLookupErrorSchema },
|
|
157
|
+
{ status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
|
|
73
158
|
],
|
|
74
159
|
}
|
|
75
160
|
|
|
@@ -4,6 +4,10 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { Tenant } from '@open-mercato/core/modules/directory/data/entities'
|
|
6
6
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
7
|
+
import { getCachedRateLimiterService } from '@open-mercato/core/bootstrap'
|
|
8
|
+
import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
|
|
9
|
+
import { checkRateLimit, getClientIp, rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
10
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
11
|
|
|
8
12
|
export const metadata = {
|
|
9
13
|
path: '/directory/tenants/lookup',
|
|
@@ -16,7 +20,48 @@ const tenantLookupQuerySchema = z.object({
|
|
|
16
20
|
tenantId: z.string().uuid(),
|
|
17
21
|
})
|
|
18
22
|
|
|
23
|
+
// Unauthenticated login/onboarding resolver: it returns the tenant name to anyone
|
|
24
|
+
// holding the id, so cap probes per IP to keep name-harvesting from leaked ids —
|
|
25
|
+
// and the unauthenticated DB query behind it — bounded and noisy.
|
|
26
|
+
//
|
|
27
|
+
// The cap is deliberately generous: the login screen resolves the tenant on every
|
|
28
|
+
// render and a workforce behind one NAT egress IP shares this budget, while a
|
|
29
|
+
// tripped lookup renders as an invalid tenant. 60/min/IP stays clear of that.
|
|
30
|
+
// Tune per deployment with RATE_LIMIT_DIRECTORY_TENANT_LOOKUP_POINTS / _DURATION /
|
|
31
|
+
// _BLOCK_DURATION.
|
|
32
|
+
const tenantLookupIpRateLimitConfig = readEndpointRateLimitConfig('DIRECTORY_TENANT_LOOKUP', {
|
|
33
|
+
points: 60,
|
|
34
|
+
duration: 60,
|
|
35
|
+
blockDuration: 60,
|
|
36
|
+
keyPrefix: 'directory-tenant-lookup',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Fail-open, like auth's checkAuthRateLimit: this resolver sits on the unauthenticated
|
|
40
|
+
// login bootstrap path, so a limiter outage must degrade to unlimited lookups rather
|
|
41
|
+
// than 500 the login screen.
|
|
42
|
+
async function enforceTenantLookupRateLimit(req: Request): Promise<NextResponse | null> {
|
|
43
|
+
try {
|
|
44
|
+
const rateLimiterService = getCachedRateLimiterService()
|
|
45
|
+
if (!rateLimiterService) return null
|
|
46
|
+
const clientIp = getClientIp(req, rateLimiterService.trustProxyDepth)
|
|
47
|
+
if (!clientIp) return null
|
|
48
|
+
const { translate } = await resolveTranslations()
|
|
49
|
+
return await checkRateLimit(
|
|
50
|
+
rateLimiterService,
|
|
51
|
+
tenantLookupIpRateLimitConfig,
|
|
52
|
+
clientIp,
|
|
53
|
+
translate('api.errors.rateLimit', 'Too many requests. Please try again later.'),
|
|
54
|
+
)
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
19
60
|
export async function GET(req: Request) {
|
|
61
|
+
// Consume the limit before validation or DB work, so junk input cannot bypass it.
|
|
62
|
+
const rateLimitResponse = await enforceTenantLookupRateLimit(req)
|
|
63
|
+
if (rateLimitResponse) return rateLimitResponse
|
|
64
|
+
|
|
20
65
|
const url = new URL(req.url)
|
|
21
66
|
const tenantId = url.searchParams.get('tenantId') || url.searchParams.get('tenant') || ''
|
|
22
67
|
const parsed = tenantLookupQuerySchema.safeParse({ tenantId })
|
|
@@ -62,6 +107,7 @@ const tenantLookupDoc: OpenApiMethodDoc = {
|
|
|
62
107
|
errors: [
|
|
63
108
|
{ status: 400, description: 'Invalid tenant id', schema: tenantLookupErrorSchema },
|
|
64
109
|
{ status: 404, description: 'Tenant not found', schema: tenantLookupErrorSchema },
|
|
110
|
+
{ status: 429, description: 'Too many tenant lookups', schema: rateLimitErrorSchema },
|
|
65
111
|
],
|
|
66
112
|
}
|
|
67
113
|
|
|
@@ -17,7 +17,16 @@ import {
|
|
|
17
17
|
} from '../data/validators'
|
|
18
18
|
import { plannerAvailabilityRuleSetCrudEvents } from '../lib/crud'
|
|
19
19
|
import { makeCreateRedo } from '@open-mercato/shared/lib/commands/redo'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
applyScopeToWhere,
|
|
22
|
+
commandActorScope,
|
|
23
|
+
ensureOrganizationScope,
|
|
24
|
+
ensureTenantScope,
|
|
25
|
+
explicitPlannerCommandScope,
|
|
26
|
+
extractUndoPayload,
|
|
27
|
+
scopeForDecryption,
|
|
28
|
+
type PlannerCommandScope,
|
|
29
|
+
} from './shared'
|
|
21
30
|
import { E } from '#generated/entities.ids.generated'
|
|
22
31
|
|
|
23
32
|
const availabilityRuleSetCrudIndexer: CrudIndexerConfig<PlannerAvailabilityRuleSet> = {
|
|
@@ -43,13 +52,14 @@ type AvailabilityRuleSetUndoPayload = {
|
|
|
43
52
|
async function loadAvailabilityRuleSetSnapshot(
|
|
44
53
|
em: EntityManager,
|
|
45
54
|
id: string,
|
|
55
|
+
scope: PlannerCommandScope,
|
|
46
56
|
): Promise<AvailabilityRuleSetSnapshot | null> {
|
|
47
57
|
const ruleSet = await findOneWithDecryption(
|
|
48
58
|
em,
|
|
49
59
|
PlannerAvailabilityRuleSet,
|
|
50
|
-
{ id },
|
|
60
|
+
applyScopeToWhere<PlannerAvailabilityRuleSet>({ id }, scope),
|
|
51
61
|
undefined,
|
|
52
|
-
|
|
62
|
+
scopeForDecryption(scope),
|
|
53
63
|
)
|
|
54
64
|
if (!ruleSet) return null
|
|
55
65
|
const custom = await loadCustomFieldSnapshot(em, {
|
|
@@ -114,15 +124,23 @@ const createAvailabilityRuleSetCommand: CommandHandler<PlannerAvailabilityRuleSe
|
|
|
114
124
|
})
|
|
115
125
|
return { ruleSetId: record.id }
|
|
116
126
|
},
|
|
117
|
-
captureAfter: async (
|
|
127
|
+
captureAfter: async (input, result, ctx) => {
|
|
118
128
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
119
|
-
const snapshot = await loadAvailabilityRuleSetSnapshot(
|
|
129
|
+
const snapshot = await loadAvailabilityRuleSetSnapshot(
|
|
130
|
+
em,
|
|
131
|
+
result.ruleSetId,
|
|
132
|
+
explicitPlannerCommandScope(input.tenantId, input.organizationId),
|
|
133
|
+
)
|
|
120
134
|
if (!snapshot) return null
|
|
121
135
|
return snapshot
|
|
122
136
|
},
|
|
123
|
-
buildLog: async ({ result, ctx }) => {
|
|
137
|
+
buildLog: async ({ input, result, ctx }) => {
|
|
124
138
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
125
|
-
const snapshot = await loadAvailabilityRuleSetSnapshot(
|
|
139
|
+
const snapshot = await loadAvailabilityRuleSetSnapshot(
|
|
140
|
+
em,
|
|
141
|
+
result.ruleSetId,
|
|
142
|
+
explicitPlannerCommandScope(input.tenantId, input.organizationId),
|
|
143
|
+
)
|
|
126
144
|
if (!snapshot) return null
|
|
127
145
|
const { translate } = await resolveTranslations()
|
|
128
146
|
return {
|
|
@@ -190,19 +208,20 @@ const updateAvailabilityRuleSetCommand: CommandHandler<PlannerAvailabilityRuleSe
|
|
|
190
208
|
async prepare(input, ctx) {
|
|
191
209
|
const { parsed } = parseWithCustomFields(plannerAvailabilityRuleSetUpdateSchema, input)
|
|
192
210
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
193
|
-
const snapshot = await loadAvailabilityRuleSetSnapshot(em, parsed.id)
|
|
211
|
+
const snapshot = await loadAvailabilityRuleSetSnapshot(em, parsed.id, commandActorScope(ctx))
|
|
194
212
|
if (!snapshot) return {}
|
|
195
213
|
return { before: snapshot }
|
|
196
214
|
},
|
|
197
215
|
async execute(input, ctx) {
|
|
198
216
|
const { parsed, custom } = parseWithCustomFields(plannerAvailabilityRuleSetUpdateSchema, input)
|
|
199
217
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
218
|
+
const scope = commandActorScope(ctx)
|
|
200
219
|
const record = await findOneWithDecryption(
|
|
201
220
|
em,
|
|
202
221
|
PlannerAvailabilityRuleSet,
|
|
203
|
-
{ id: parsed.id, deletedAt: null },
|
|
222
|
+
applyScopeToWhere<PlannerAvailabilityRuleSet>({ id: parsed.id, deletedAt: null }, scope),
|
|
204
223
|
undefined,
|
|
205
|
-
|
|
224
|
+
scopeForDecryption(scope),
|
|
206
225
|
)
|
|
207
226
|
if (!record) throw new CrudHttpError(404, { error: 'Planner availability rule set not found.' })
|
|
208
227
|
ensureTenantScope(ctx, record.tenantId)
|
|
@@ -240,7 +259,11 @@ const updateAvailabilityRuleSetCommand: CommandHandler<PlannerAvailabilityRuleSe
|
|
|
240
259
|
const before = snapshots.before as AvailabilityRuleSetSnapshot | undefined
|
|
241
260
|
if (!before) return null
|
|
242
261
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
243
|
-
const after = await loadAvailabilityRuleSetSnapshot(
|
|
262
|
+
const after = await loadAvailabilityRuleSetSnapshot(
|
|
263
|
+
em,
|
|
264
|
+
before.id,
|
|
265
|
+
explicitPlannerCommandScope(before.tenantId, before.organizationId),
|
|
266
|
+
)
|
|
244
267
|
if (!after) return null
|
|
245
268
|
const changes = buildChanges(before as unknown as Record<string, unknown>, after as unknown as Record<string, unknown>, [
|
|
246
269
|
'name',
|
|
@@ -319,7 +342,7 @@ const deleteAvailabilityRuleSetCommand: CommandHandler<{ id?: string }, { ruleSe
|
|
|
319
342
|
const id = input?.id
|
|
320
343
|
if (!id) throw new CrudHttpError(400, { error: 'Availability rule set id is required.' })
|
|
321
344
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
322
|
-
const snapshot = await loadAvailabilityRuleSetSnapshot(em, id)
|
|
345
|
+
const snapshot = await loadAvailabilityRuleSetSnapshot(em, id, commandActorScope(ctx))
|
|
323
346
|
if (!snapshot) return {}
|
|
324
347
|
return { before: snapshot }
|
|
325
348
|
},
|
|
@@ -327,12 +350,13 @@ const deleteAvailabilityRuleSetCommand: CommandHandler<{ id?: string }, { ruleSe
|
|
|
327
350
|
const id = input?.id
|
|
328
351
|
if (!id) throw new CrudHttpError(400, { error: 'Availability rule set id is required.' })
|
|
329
352
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
353
|
+
const scope = commandActorScope(ctx)
|
|
330
354
|
const record = await findOneWithDecryption(
|
|
331
355
|
em,
|
|
332
356
|
PlannerAvailabilityRuleSet,
|
|
333
|
-
{ id, deletedAt: null },
|
|
357
|
+
applyScopeToWhere<PlannerAvailabilityRuleSet>({ id, deletedAt: null }, scope),
|
|
334
358
|
undefined,
|
|
335
|
-
|
|
359
|
+
scopeForDecryption(scope),
|
|
336
360
|
)
|
|
337
361
|
if (!record) throw new CrudHttpError(404, { error: 'Planner availability rule set not found.' })
|
|
338
362
|
ensureTenantScope(ctx, record.tenantId)
|
|
@@ -10,9 +10,16 @@ import {
|
|
|
10
10
|
type PlannerAvailabilityRuleCreateInput,
|
|
11
11
|
type PlannerAvailabilityRuleUpdateInput,
|
|
12
12
|
} from '../data/validators'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
applyScopeToWhere,
|
|
15
|
+
commandActorScope,
|
|
16
|
+
ensureOrganizationScope,
|
|
17
|
+
ensureTenantScope,
|
|
18
|
+
explicitPlannerCommandScope,
|
|
19
|
+
extractUndoPayload,
|
|
20
|
+
type PlannerCommandScope,
|
|
21
|
+
} from './shared'
|
|
14
22
|
import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
|
|
15
|
-
import { extractUndoPayload } from './shared'
|
|
16
23
|
import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
|
|
17
24
|
|
|
18
25
|
const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
|
|
@@ -38,8 +45,15 @@ type AvailabilityRuleUndoPayload = {
|
|
|
38
45
|
after?: AvailabilityRuleSnapshot | null
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
async function loadAvailabilityRuleSnapshot(
|
|
42
|
-
|
|
48
|
+
async function loadAvailabilityRuleSnapshot(
|
|
49
|
+
em: EntityManager,
|
|
50
|
+
id: string,
|
|
51
|
+
scope: PlannerCommandScope,
|
|
52
|
+
): Promise<AvailabilityRuleSnapshot | null> {
|
|
53
|
+
const record = await em.findOne(
|
|
54
|
+
PlannerAvailabilityRule,
|
|
55
|
+
applyScopeToWhere<PlannerAvailabilityRule>({ id }, scope),
|
|
56
|
+
)
|
|
43
57
|
if (!record) return null
|
|
44
58
|
return {
|
|
45
59
|
id: record.id,
|
|
@@ -125,9 +139,13 @@ const createAvailabilityRuleCommand: CommandHandler<PlannerAvailabilityRuleCreat
|
|
|
125
139
|
await em.flush()
|
|
126
140
|
return { ruleId: record.id }
|
|
127
141
|
},
|
|
128
|
-
captureAfter: async (
|
|
142
|
+
captureAfter: async (input, result, ctx) => {
|
|
129
143
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
130
|
-
return loadAvailabilityRuleSnapshot(
|
|
144
|
+
return loadAvailabilityRuleSnapshot(
|
|
145
|
+
em,
|
|
146
|
+
result.ruleId,
|
|
147
|
+
explicitPlannerCommandScope(input.tenantId, input.organizationId),
|
|
148
|
+
)
|
|
131
149
|
},
|
|
132
150
|
buildLog: async ({ input, result, ctx, snapshots }) => {
|
|
133
151
|
const snapshot = snapshots.after as AvailabilityRuleSnapshot | undefined
|
|
@@ -171,13 +189,19 @@ const updateAvailabilityRuleCommand: CommandHandler<PlannerAvailabilityRuleUpdat
|
|
|
171
189
|
async prepare(input, ctx) {
|
|
172
190
|
const parsed = plannerAvailabilityRuleUpdateSchema.parse(input)
|
|
173
191
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
174
|
-
const snapshot = await loadAvailabilityRuleSnapshot(em, parsed.id)
|
|
192
|
+
const snapshot = await loadAvailabilityRuleSnapshot(em, parsed.id, commandActorScope(ctx))
|
|
175
193
|
return snapshot ? { before: snapshot } : {}
|
|
176
194
|
},
|
|
177
195
|
async execute(input, ctx) {
|
|
178
196
|
const parsed = plannerAvailabilityRuleUpdateSchema.parse(input)
|
|
179
197
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
180
|
-
const record = await em.findOne(
|
|
198
|
+
const record = await em.findOne(
|
|
199
|
+
PlannerAvailabilityRule,
|
|
200
|
+
applyScopeToWhere<PlannerAvailabilityRule>(
|
|
201
|
+
{ id: parsed.id, deletedAt: null },
|
|
202
|
+
commandActorScope(ctx),
|
|
203
|
+
),
|
|
204
|
+
)
|
|
181
205
|
if (!record) throw new CrudHttpError(404, { error: 'Planner availability rule not found.' })
|
|
182
206
|
ensureTenantScope(ctx, record.tenantId)
|
|
183
207
|
ensureOrganizationScope(ctx, record.organizationId)
|
|
@@ -208,7 +232,13 @@ const updateAvailabilityRuleCommand: CommandHandler<PlannerAvailabilityRuleUpdat
|
|
|
208
232
|
buildLog: async ({ snapshots, input, result, ctx }) => {
|
|
209
233
|
const before = snapshots.before as AvailabilityRuleSnapshot | undefined
|
|
210
234
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
211
|
-
const afterSnapshot = before
|
|
235
|
+
const afterSnapshot = before
|
|
236
|
+
? await loadAvailabilityRuleSnapshot(
|
|
237
|
+
em,
|
|
238
|
+
before.id,
|
|
239
|
+
explicitPlannerCommandScope(before.tenantId, before.organizationId),
|
|
240
|
+
)
|
|
241
|
+
: null
|
|
212
242
|
const { translate } = await resolveTranslations()
|
|
213
243
|
return {
|
|
214
244
|
actionLabel: translate('planner.audit.availability.update', 'Update availability rule'),
|
|
@@ -242,14 +272,17 @@ const deleteAvailabilityRuleCommand: CommandHandler<{ id?: string }, { ruleId: s
|
|
|
242
272
|
const id = input?.id
|
|
243
273
|
if (!id) return {}
|
|
244
274
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
245
|
-
const snapshot = await loadAvailabilityRuleSnapshot(em, id)
|
|
275
|
+
const snapshot = await loadAvailabilityRuleSnapshot(em, id, commandActorScope(ctx))
|
|
246
276
|
return snapshot ? { before: snapshot } : {}
|
|
247
277
|
},
|
|
248
278
|
async execute(input, ctx) {
|
|
249
279
|
const id = input?.id
|
|
250
280
|
if (!id) throw new CrudHttpError(400, { error: 'Availability rule id is required.' })
|
|
251
281
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
252
|
-
const record = await em.findOne(
|
|
282
|
+
const record = await em.findOne(
|
|
283
|
+
PlannerAvailabilityRule,
|
|
284
|
+
applyScopeToWhere<PlannerAvailabilityRule>({ id, deletedAt: null }, commandActorScope(ctx)),
|
|
285
|
+
)
|
|
253
286
|
if (!record) return { ruleId: id }
|
|
254
287
|
ensureTenantScope(ctx, record.tenantId)
|
|
255
288
|
ensureOrganizationScope(ctx, record.organizationId)
|
|
@@ -1,4 +1,55 @@
|
|
|
1
|
+
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
1
2
|
import { ensureOrganizationScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
|
|
2
3
|
import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
|
|
4
|
+
import type { FilterQuery } from '@mikro-orm/postgresql'
|
|
3
5
|
|
|
4
6
|
export { ensureOrganizationScope, ensureTenantScope, extractUndoPayload }
|
|
7
|
+
|
|
8
|
+
export type PlannerCommandScope = {
|
|
9
|
+
tenantId: string | null
|
|
10
|
+
organizationId: string | null
|
|
11
|
+
requireTenant: boolean
|
|
12
|
+
requireOrganization: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function commandActorScope(ctx: CommandRuntimeContext): PlannerCommandScope {
|
|
16
|
+
const isPrivilegedActor = ctx.auth?.isSuperAdmin === true || ctx.systemActor === true
|
|
17
|
+
const tenantId = isPrivilegedActor ? null : (ctx.auth?.tenantId ?? ctx.organizationScope?.tenantId ?? null)
|
|
18
|
+
const organizationId = isPrivilegedActor ? null : (ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null)
|
|
19
|
+
const organizationUnrestricted =
|
|
20
|
+
isPrivilegedActor || (organizationId === null && tenantId !== null && ctx.organizationScope?.allowedIds === null)
|
|
21
|
+
return {
|
|
22
|
+
tenantId,
|
|
23
|
+
organizationId: organizationUnrestricted ? null : organizationId,
|
|
24
|
+
requireTenant: !isPrivilegedActor,
|
|
25
|
+
requireOrganization: !organizationUnrestricted,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function explicitPlannerCommandScope(
|
|
30
|
+
tenantId: string | null,
|
|
31
|
+
organizationId: string | null,
|
|
32
|
+
): PlannerCommandScope {
|
|
33
|
+
return {
|
|
34
|
+
tenantId,
|
|
35
|
+
organizationId,
|
|
36
|
+
requireTenant: true,
|
|
37
|
+
requireOrganization: true,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function applyScopeToWhere<TEntity extends object>(
|
|
42
|
+
where: FilterQuery<TEntity>,
|
|
43
|
+
scope: PlannerCommandScope,
|
|
44
|
+
): FilterQuery<TEntity> {
|
|
45
|
+
const scoped = { ...(where as Record<string, unknown>) }
|
|
46
|
+
if (scope.requireTenant || scope.tenantId !== null) scoped.tenantId = scope.tenantId
|
|
47
|
+
if (scope.requireOrganization || scope.organizationId !== null) scoped.organizationId = scope.organizationId
|
|
48
|
+
return scoped as FilterQuery<TEntity>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function scopeForDecryption(
|
|
52
|
+
scope: PlannerCommandScope,
|
|
53
|
+
): { tenantId: string | null; organizationId: string | null } {
|
|
54
|
+
return { tenantId: scope.tenantId, organizationId: scope.organizationId }
|
|
55
|
+
}
|