@open-mercato/core 0.6.6-develop.6402.1.080aab8ec9 → 0.6.6-develop.6404.1.e2f8d5025a
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/catalog/widgets/injection/product-seo/state.js +9 -0
- package/dist/modules/catalog/widgets/injection/product-seo/state.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/validation.js +29 -15
- package/dist/modules/catalog/widgets/injection/product-seo/validation.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +5 -1
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
- package/dist/modules/planner/api/availability-weekly.js +11 -1
- package/dist/modules/planner/api/availability-weekly.js.map +2 -2
- package/dist/modules/planner/commands/availability-weekly.js +34 -3
- package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -6
- package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/i18n/de.json +10 -0
- package/src/modules/catalog/i18n/en.json +10 -0
- package/src/modules/catalog/i18n/es.json +10 -0
- package/src/modules/catalog/i18n/pl.json +10 -0
- package/src/modules/catalog/widgets/injection/product-seo/state.ts +17 -0
- package/src/modules/catalog/widgets/injection/product-seo/validation.ts +38 -15
- package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +7 -1
- package/src/modules/catalog/widgets/injection/product-seo/widget.ts +2 -2
- package/src/modules/planner/api/availability-weekly.ts +10 -0
- package/src/modules/planner/commands/availability-weekly.ts +46 -8
- package/src/modules/planner/components/AvailabilityRulesEditor.tsx +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -248,16 +248,16 @@
|
|
|
248
248
|
"zod": "^4.4.3"
|
|
249
249
|
},
|
|
250
250
|
"peerDependencies": {
|
|
251
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
252
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
253
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
251
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
252
|
+
"@open-mercato/shared": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
253
|
+
"@open-mercato/ui": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
254
254
|
"react": "^19.0.0",
|
|
255
255
|
"react-dom": "^19.0.0"
|
|
256
256
|
},
|
|
257
257
|
"devDependencies": {
|
|
258
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
259
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
260
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
258
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
259
|
+
"@open-mercato/shared": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
260
|
+
"@open-mercato/ui": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
261
261
|
"@testing-library/dom": "^10.4.1",
|
|
262
262
|
"@testing-library/jest-dom": "^6.9.1",
|
|
263
263
|
"@testing-library/react": "^16.3.1",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Titel ({{count}} Zeichen)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Zu lang",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Zu kurz",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "SEO-Helfer: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "SEO-Helfer: {{count}} Probleme gefunden. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Geben Sie eine Beschreibung an, damit Suchmaschinen dieses Produkt besser verstehen.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "Die Beschreibung ist für gutes SEO zu kurz (mind. 50 Zeichen).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "Der Titel ist für optimales SEO zu lang (max. 60 Zeichen).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "Der Titel ist für gutes SEO zu kurz (mind. 10 Zeichen).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Fügen Sie eine Produktbeschreibung für besseres SEO hinzu.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "Die Beschreibung ist zu kurz (mind. 50 Zeichen).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "Der Titel ist zu lang (max. 60 Zeichen empfohlen).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "Der Titel ist zu kurz (mind. 10 Zeichen).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Eindeutige Produktkennung. Buchstaben, Zahlen, Bindestriche, Unterstriche, Punkte.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Weiter",
|
|
351
361
|
"catalog.products.create.steps.general": "Allgemeine Daten",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Title ({{count}} chars)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Too long",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Too short",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "SEO helper: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "SEO helper: {{count}} issues found. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Provide a description to help search engines understand this product.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "Description is too short for good SEO (min 50 characters).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "Title is too long for optimal SEO (max 60 characters).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "Title is too short for good SEO (min 10 characters).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Add a product description for better SEO.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "Description is too short (min 50 characters).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "Title is too long (max 60 characters recommended).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "Title is too short (min 10 characters).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Unique product identifier. Letters, numbers, hyphens, underscores, periods.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Continue",
|
|
351
361
|
"catalog.products.create.steps.general": "General data",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Título ({{count}} caracteres)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Muy largo",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Muy corto",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "Asistente SEO: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "Asistente SEO: {{count}} problemas encontrados. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Proporciona una descripción para ayudar a los motores de búsqueda a entender este producto.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "La descripción es demasiado corta para un buen SEO (mín. 50 caracteres).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "El título es demasiado largo para un SEO óptimo (máx. 60 caracteres).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "El título es demasiado corto para un buen SEO (mín. 10 caracteres).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Añade una descripción de producto para mejorar el SEO.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "La descripción es demasiado corta (mín. 50 caracteres).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "El título es demasiado largo (máx. 60 caracteres recomendado).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "El título es demasiado corto (mín. 10 caracteres).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Identificador único del producto. Letras, números, guiones, guiones bajos, puntos.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Continuar",
|
|
351
361
|
"catalog.products.create.steps.general": "Datos generales",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Tytuł ({{count}} znaków)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Za długi",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Za krótki",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "Pomocnik SEO: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "Pomocnik SEO: znaleziono {{count}} problemów. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Podaj opis, aby wyszukiwarki lepiej rozumiały ten produkt.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "Opis jest za krótki dla dobrego SEO (min. 50 znaków).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "Tytuł jest za długi dla optymalnego SEO (maks. 60 znaków).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "Tytuł jest za krótki dla dobrego SEO (min. 10 znaków).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Dodaj opis produktu dla lepszego SEO.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "Opis jest za krótki (min. 50 znaków).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "Tytuł jest za długi (zalecane maks. 60 znaków).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "Tytuł jest za krótki (min. 10 znaków).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Unikalny identyfikator produktu. Litery, cyfry, myślniki, podkreślenia, kropki.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Dalej",
|
|
351
361
|
"catalog.products.create.steps.general": "Dane ogólne",
|
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
2
|
+
|
|
1
3
|
export type ProductSeoValidationPayload = {
|
|
2
4
|
ok: boolean
|
|
3
5
|
issues: string[]
|
|
4
6
|
message?: string
|
|
5
7
|
}
|
|
6
8
|
|
|
9
|
+
// The `onBeforeSave` validation hook (widget.ts) is a plain side-effect handler
|
|
10
|
+
// with no React context, so it cannot call `useT`. The widget component (which
|
|
11
|
+
// does have `useT`) publishes its translator here on mount; the hook reads it to
|
|
12
|
+
// localize the save-block message (#3299). Undefined until the widget mounts —
|
|
13
|
+
// callers fall back to English via `evaluateProductSeo`'s default translator.
|
|
14
|
+
let productSeoTranslator: TranslateFn | null = null
|
|
15
|
+
|
|
16
|
+
export function setProductSeoTranslator(t: TranslateFn | null) {
|
|
17
|
+
productSeoTranslator = t
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getProductSeoTranslator(): TranslateFn | undefined {
|
|
21
|
+
return productSeoTranslator ?? undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
7
24
|
type Listener = (payload: ProductSeoValidationPayload) => void
|
|
8
25
|
|
|
9
26
|
const listeners = new Set<Listener>()
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
2
|
+
|
|
1
3
|
export type ProductSeoEvaluation = {
|
|
2
4
|
ok: boolean
|
|
3
5
|
issues: string[]
|
|
@@ -5,42 +7,63 @@ export type ProductSeoEvaluation = {
|
|
|
5
7
|
message?: string
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
// English-fallback translator: keeps this pure module usable (and unit-testable)
|
|
11
|
+
// when no real `t` is threaded in — every string still ships its English default,
|
|
12
|
+
// interpolating `{{param}}` placeholders the same way the real translator does.
|
|
13
|
+
const englishFallback: TranslateFn = (_key, fallbackOrParams, params) => {
|
|
14
|
+
const template = typeof fallbackOrParams === 'string' ? fallbackOrParams : ''
|
|
15
|
+
const resolvedParams = typeof fallbackOrParams === 'string' ? params : fallbackOrParams
|
|
16
|
+
if (!resolvedParams) return template
|
|
17
|
+
return template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleKey, singleKey) => {
|
|
18
|
+
const key = doubleKey ?? singleKey
|
|
19
|
+
const value = resolvedParams[key]
|
|
20
|
+
return value === undefined ? match : String(value)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const K = 'catalog.products.create.seoWidget.validation'
|
|
9
25
|
|
|
10
|
-
export function buildSeoBlockMessage(issues: string[]): string {
|
|
11
|
-
if (issues.length
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
export function buildSeoBlockMessage(issues: string[], t: TranslateFn = englishFallback): string {
|
|
27
|
+
if (issues.length <= 3) {
|
|
28
|
+
return t(`${K}.block.list`, 'SEO helper: {{issues}}', { issues: issues.join(' ') })
|
|
29
|
+
}
|
|
30
|
+
return t(`${K}.block.summary`, 'SEO helper: {{count}} issues found. {{issues}}', {
|
|
31
|
+
count: issues.length,
|
|
32
|
+
issues: issues.slice(0, 2).join(' '),
|
|
33
|
+
})
|
|
14
34
|
}
|
|
15
35
|
|
|
16
|
-
export function evaluateProductSeo(
|
|
36
|
+
export function evaluateProductSeo(
|
|
37
|
+
data: Record<string, unknown> | null | undefined,
|
|
38
|
+
t: TranslateFn = englishFallback,
|
|
39
|
+
): ProductSeoEvaluation {
|
|
17
40
|
const issues: string[] = []
|
|
18
41
|
const fieldErrors: Record<string, string> = {}
|
|
19
42
|
|
|
20
43
|
const title = (data?.title as unknown) || (data?.name as unknown)
|
|
21
44
|
if (typeof title === 'string' && title.length > 0) {
|
|
22
45
|
if (title.length < 10) {
|
|
23
|
-
issues.push('Title is too short (min 10 characters).')
|
|
24
|
-
fieldErrors.title = 'Title is too short for good SEO (min 10 characters).'
|
|
46
|
+
issues.push(t(`${K}.issue.titleTooShort`, 'Title is too short (min 10 characters).'))
|
|
47
|
+
fieldErrors.title = t(`${K}.fieldError.titleTooShort`, 'Title is too short for good SEO (min 10 characters).')
|
|
25
48
|
} else if (title.length > 60) {
|
|
26
|
-
issues.push('Title is too long (max 60 characters recommended).')
|
|
27
|
-
fieldErrors.title = 'Title is too long for optimal SEO (max 60 characters).'
|
|
49
|
+
issues.push(t(`${K}.issue.titleTooLong`, 'Title is too long (max 60 characters recommended).'))
|
|
50
|
+
fieldErrors.title = t(`${K}.fieldError.titleTooLong`, 'Title is too long for optimal SEO (max 60 characters).')
|
|
28
51
|
}
|
|
29
52
|
}
|
|
30
53
|
|
|
31
54
|
const description = data?.description
|
|
32
55
|
if (typeof description === 'string') {
|
|
33
56
|
if (description.trim().length === 0) {
|
|
34
|
-
issues.push('Add a product description for better SEO.')
|
|
35
|
-
fieldErrors.description = 'Provide a description to help search engines understand this product.'
|
|
57
|
+
issues.push(t(`${K}.issue.descriptionMissing`, 'Add a product description for better SEO.'))
|
|
58
|
+
fieldErrors.description = t(`${K}.fieldError.descriptionMissing`, 'Provide a description to help search engines understand this product.')
|
|
36
59
|
} else if (description.length < 50) {
|
|
37
|
-
issues.push('Description is too short (min 50 characters).')
|
|
38
|
-
fieldErrors.description = 'Description is too short for good SEO (min 50 characters).'
|
|
60
|
+
issues.push(t(`${K}.issue.descriptionTooShort`, 'Description is too short (min 50 characters).'))
|
|
61
|
+
fieldErrors.description = t(`${K}.fieldError.descriptionTooShort`, 'Description is too short for good SEO (min 50 characters).')
|
|
39
62
|
}
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
if (issues.length) {
|
|
43
|
-
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues) }
|
|
66
|
+
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues, t) }
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
return { ok: true, issues: [], fieldErrors: {} }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
|
|
4
|
-
import { subscribeProductSeoValidation } from './state'
|
|
4
|
+
import { subscribeProductSeoValidation, setProductSeoTranslator } from './state'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'
|
|
7
7
|
import { Alert } from '@open-mercato/ui/primitives/alert'
|
|
@@ -36,6 +36,12 @@ function computeIssueKeys(title: string, description: string): IssueKey[] {
|
|
|
36
36
|
|
|
37
37
|
export default function ProductSeoWidget({ data }: InjectionWidgetComponentProps<unknown, SeoData>) {
|
|
38
38
|
const t = useT()
|
|
39
|
+
// Expose the translator to the module's `onBeforeSave` hook (which has no React
|
|
40
|
+
// context) so the save-block message is localized (#3299).
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
setProductSeoTranslator(t)
|
|
43
|
+
return () => setProductSeoTranslator(null)
|
|
44
|
+
}, [t])
|
|
39
45
|
const title = (data?.title || data?.name || '') ?? ''
|
|
40
46
|
const description = data?.description ?? ''
|
|
41
47
|
const baselineIssueKeys = React.useMemo(() => computeIssueKeys(title, description), [title, description])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
2
|
import ProductSeoWidget from './widget.client'
|
|
3
|
-
import { publishProductSeoValidation } from './state'
|
|
3
|
+
import { publishProductSeoValidation, getProductSeoTranslator } from './state'
|
|
4
4
|
import { evaluateProductSeo } from './validation'
|
|
5
5
|
|
|
6
6
|
const widget: InjectionWidgetModule<any, any> = {
|
|
@@ -16,7 +16,7 @@ const widget: InjectionWidgetModule<any, any> = {
|
|
|
16
16
|
Widget: ProductSeoWidget,
|
|
17
17
|
eventHandlers: {
|
|
18
18
|
onBeforeSave: async (data) => {
|
|
19
|
-
const evaluation = evaluateProductSeo(data as Record<string, unknown
|
|
19
|
+
const evaluation = evaluateProductSeo(data as Record<string, unknown>, getProductSeoTranslator())
|
|
20
20
|
|
|
21
21
|
if (!evaluation.ok) {
|
|
22
22
|
publishProductSeoValidation({ ok: false, issues: evaluation.issues, message: evaluation.message })
|
|
@@ -123,6 +123,16 @@ export const openApi = {
|
|
|
123
123
|
{ status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },
|
|
124
124
|
{ status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
|
|
125
125
|
{ status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },
|
|
126
|
+
{
|
|
127
|
+
status: 409,
|
|
128
|
+
description: 'Optimistic lock conflict',
|
|
129
|
+
schema: z.object({
|
|
130
|
+
error: z.string(),
|
|
131
|
+
code: z.literal('optimistic_lock_conflict'),
|
|
132
|
+
currentUpdatedAt: z.string(),
|
|
133
|
+
expectedUpdatedAt: z.string(),
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
126
136
|
],
|
|
127
137
|
},
|
|
128
138
|
},
|
|
@@ -8,14 +8,24 @@ import {
|
|
|
8
8
|
type PlannerAvailabilityWeeklyReplaceInput,
|
|
9
9
|
} from '../data/validators'
|
|
10
10
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
11
|
-
import
|
|
11
|
+
import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
12
12
|
import {
|
|
13
13
|
enforceCommandOptimisticLock,
|
|
14
14
|
enforceRecordGoneIsConflict,
|
|
15
15
|
} from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
16
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
17
|
+
import type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
|
|
18
|
+
import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
|
|
16
19
|
import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'
|
|
20
|
+
import { plannerAvailabilityRuleSetCrudEvents } from '../lib/crud'
|
|
21
|
+
import { E } from '#generated/entities.ids.generated'
|
|
17
22
|
|
|
18
23
|
const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
|
|
24
|
+
const AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND = 'planner.availability-rule-set'
|
|
25
|
+
|
|
26
|
+
const availabilityRuleSetCrudIndexer: CrudIndexerConfig<PlannerAvailabilityRuleSet> = {
|
|
27
|
+
entityType: E.planner.planner_availability_rule_set,
|
|
28
|
+
}
|
|
19
29
|
|
|
20
30
|
// Canonical resource kind for the parent rule set, matching the tag the CRUD
|
|
21
31
|
// factory derives for `planner.availability-rule-sets.*` commands. Weekly
|
|
@@ -95,6 +105,14 @@ function toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): Availabili
|
|
|
95
105
|
}
|
|
96
106
|
}
|
|
97
107
|
|
|
108
|
+
function nextRuleSetUpdatedAt(current: Date | null | undefined, fallback: Date): Date {
|
|
109
|
+
const currentMs = current instanceof Date ? current.getTime() : Number.NaN
|
|
110
|
+
if (Number.isFinite(currentMs) && fallback.getTime() <= currentMs) {
|
|
111
|
+
return new Date(currentMs + 1)
|
|
112
|
+
}
|
|
113
|
+
return fallback
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
async function loadWeeklySnapshots(
|
|
99
117
|
em: EntityManager,
|
|
100
118
|
params: {
|
|
@@ -173,12 +191,12 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
|
|
|
173
191
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
174
192
|
const now = new Date()
|
|
175
193
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
194
|
+
// The weekly rules of a rule set are a sub-resource of that rule set: the
|
|
195
|
+
// parent is the optimistic-lock consistency boundary. Guard the parent's
|
|
196
|
+
// version (so a stale weekly save loses to a concurrent rule-set
|
|
197
|
+
// change/delete) and bump its `updated_at` after the replace (so a
|
|
198
|
+
// concurrent rule-set delete/update with a stale token conflicts). See #2927.
|
|
199
|
+
const touchedRuleSet = await em.transactional(async (trx): Promise<PlannerAvailabilityRuleSet | null> => {
|
|
182
200
|
let ruleSet: PlannerAvailabilityRuleSet | null = null
|
|
183
201
|
if (parsed.subjectType === 'ruleset') {
|
|
184
202
|
ruleSet = await trx.findOne(PlannerAvailabilityRuleSet, {
|
|
@@ -250,13 +268,30 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
|
|
|
250
268
|
})
|
|
251
269
|
|
|
252
270
|
if (ruleSet) {
|
|
253
|
-
ruleSet.updatedAt = now
|
|
271
|
+
ruleSet.updatedAt = nextRuleSetUpdatedAt(ruleSet.updatedAt, now)
|
|
254
272
|
trx.persist(ruleSet)
|
|
255
273
|
}
|
|
256
274
|
|
|
257
275
|
await trx.flush()
|
|
276
|
+
return ruleSet
|
|
258
277
|
})
|
|
259
278
|
|
|
279
|
+
if (touchedRuleSet) {
|
|
280
|
+
const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
|
|
281
|
+
await emitCrudSideEffects({
|
|
282
|
+
dataEngine,
|
|
283
|
+
action: 'updated',
|
|
284
|
+
entity: touchedRuleSet,
|
|
285
|
+
identifiers: {
|
|
286
|
+
id: touchedRuleSet.id,
|
|
287
|
+
organizationId: touchedRuleSet.organizationId,
|
|
288
|
+
tenantId: touchedRuleSet.tenantId,
|
|
289
|
+
},
|
|
290
|
+
events: plannerAvailabilityRuleSetCrudEvents,
|
|
291
|
+
indexer: availabilityRuleSetCrudIndexer,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
return { ok: true }
|
|
261
296
|
},
|
|
262
297
|
buildLog: async ({ input, snapshots, ctx }) => {
|
|
@@ -284,6 +319,9 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
|
|
|
284
319
|
after,
|
|
285
320
|
} satisfies WeeklyUndoPayload,
|
|
286
321
|
},
|
|
322
|
+
context: parsed.subjectType === 'ruleset'
|
|
323
|
+
? { cacheAliases: [AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND] }
|
|
324
|
+
: null,
|
|
287
325
|
}
|
|
288
326
|
},
|
|
289
327
|
undo: async ({ logEntry, ctx }) => {
|
|
@@ -19,6 +19,7 @@ import { createCrud, deleteCrud, updateCrud } from '@open-mercato/ui/backend/uti
|
|
|
19
19
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
20
20
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
21
21
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
22
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
22
23
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
23
24
|
import { ComboboxInput, TimePicker } from '@open-mercato/ui/backend/inputs'
|
|
24
25
|
import { DictionaryEntrySelect, type DictionarySelectLabels } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
|
|
@@ -30,7 +31,6 @@ import {
|
|
|
30
31
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
31
32
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
32
33
|
import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
33
|
-
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
34
34
|
import { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'
|
|
35
35
|
import { deleteAvailabilityRuleSet } from '@open-mercato/core/modules/planner/lib/deleteAvailabilityRuleSet'
|
|
36
36
|
import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
|
|
@@ -778,6 +778,12 @@ export function AvailabilityRulesEditor({
|
|
|
778
778
|
void refreshBookedEvents()
|
|
779
779
|
}, [refreshBookedEvents])
|
|
780
780
|
|
|
781
|
+
const refreshAfterRuleSetConflict = React.useCallback(() => {
|
|
782
|
+
void refreshRuleSets()
|
|
783
|
+
void refreshRuleSetRules()
|
|
784
|
+
void refreshAvailability()
|
|
785
|
+
}, [refreshAvailability, refreshRuleSetRules, refreshRuleSets])
|
|
786
|
+
|
|
781
787
|
const weeklyDraft = React.useMemo(() => buildWeeklyDraft(activeRules), [activeRules])
|
|
782
788
|
const [weeklyWindows, setWeeklyWindows] = React.useState<WeeklyWindows>(() => cloneWeeklyWindows(weeklyDraft))
|
|
783
789
|
const weeklyWindowsRef = React.useRef<WeeklyWindows>(cloneWeeklyWindows(weeklyDraft))
|
|
@@ -893,14 +899,15 @@ export function AvailabilityRulesEditor({
|
|
|
893
899
|
if (!shouldSkipRefresh) {
|
|
894
900
|
await refreshAvailability()
|
|
895
901
|
await refreshRuleSetRules()
|
|
902
|
+
if (usingRuleSet) {
|
|
903
|
+
await refreshRuleSets()
|
|
904
|
+
}
|
|
896
905
|
}
|
|
897
906
|
if (parentRuleSet) {
|
|
898
907
|
await refreshRuleSets()
|
|
899
908
|
}
|
|
900
909
|
} catch (error) {
|
|
901
|
-
if (surfaceRecordConflict(error, t))
|
|
902
|
-
return
|
|
903
|
-
}
|
|
910
|
+
if (surfaceRecordConflict(error, t, { onRefresh: refreshAfterRuleSetConflict })) return
|
|
904
911
|
const message = error instanceof Error ? error.message : listLabels.saveWeeklyError
|
|
905
912
|
flash(message, 'error')
|
|
906
913
|
} finally {
|
|
@@ -912,8 +919,9 @@ export function AvailabilityRulesEditor({
|
|
|
912
919
|
refreshAvailability,
|
|
913
920
|
refreshRuleSetRules,
|
|
914
921
|
refreshRuleSets,
|
|
915
|
-
|
|
922
|
+
refreshAfterRuleSetConflict,
|
|
916
923
|
effectiveRulesetId,
|
|
924
|
+
ruleSets,
|
|
917
925
|
subjectId,
|
|
918
926
|
subjectType,
|
|
919
927
|
t,
|
|
@@ -1180,7 +1188,7 @@ export function AvailabilityRulesEditor({
|
|
|
1180
1188
|
refreshRuleSets,
|
|
1181
1189
|
onSuccess: () => flash(listLabels.ruleSetDeleteSuccess, 'success'),
|
|
1182
1190
|
onError: (error) => {
|
|
1183
|
-
if (surfaceRecordConflict(error, t)) return
|
|
1191
|
+
if (surfaceRecordConflict(error, t, { onRefresh: refreshAfterRuleSetConflict })) return
|
|
1184
1192
|
console.error('planner.availability-rule-sets.delete', error)
|
|
1185
1193
|
const normalized = normalizeCrudServerError(error)
|
|
1186
1194
|
flash(normalized.message ?? listLabels.ruleSetDeleteError, 'error')
|
|
@@ -1195,6 +1203,7 @@ export function AvailabilityRulesEditor({
|
|
|
1195
1203
|
listLabels.ruleSetDeleteSuccess,
|
|
1196
1204
|
onRulesetChange,
|
|
1197
1205
|
refreshAvailability,
|
|
1206
|
+
refreshAfterRuleSetConflict,
|
|
1198
1207
|
refreshRuleSets,
|
|
1199
1208
|
ruleSets,
|
|
1200
1209
|
rulesetId,
|