@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.
Files changed (27) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/widgets/injection/product-seo/state.js +9 -0
  3. package/dist/modules/catalog/widgets/injection/product-seo/state.js.map +2 -2
  4. package/dist/modules/catalog/widgets/injection/product-seo/validation.js +29 -15
  5. package/dist/modules/catalog/widgets/injection/product-seo/validation.js.map +2 -2
  6. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +5 -1
  7. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  8. package/dist/modules/catalog/widgets/injection/product-seo/widget.js +2 -2
  9. package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
  10. package/dist/modules/planner/api/availability-weekly.js +11 -1
  11. package/dist/modules/planner/api/availability-weekly.js.map +2 -2
  12. package/dist/modules/planner/commands/availability-weekly.js +34 -3
  13. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  14. package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -6
  15. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  16. package/package.json +7 -7
  17. package/src/modules/catalog/i18n/de.json +10 -0
  18. package/src/modules/catalog/i18n/en.json +10 -0
  19. package/src/modules/catalog/i18n/es.json +10 -0
  20. package/src/modules/catalog/i18n/pl.json +10 -0
  21. package/src/modules/catalog/widgets/injection/product-seo/state.ts +17 -0
  22. package/src/modules/catalog/widgets/injection/product-seo/validation.ts +38 -15
  23. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +7 -1
  24. package/src/modules/catalog/widgets/injection/product-seo/widget.ts +2 -2
  25. package/src/modules/planner/api/availability-weekly.ts +10 -0
  26. package/src/modules/planner/commands/availability-weekly.ts +46 -8
  27. 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.6402.1.080aab8ec9",
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.6402.1.080aab8ec9",
252
- "@open-mercato/shared": "0.6.6-develop.6402.1.080aab8ec9",
253
- "@open-mercato/ui": "0.6.6-develop.6402.1.080aab8ec9",
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.6402.1.080aab8ec9",
259
- "@open-mercato/shared": "0.6.6-develop.6402.1.080aab8ec9",
260
- "@open-mercato/ui": "0.6.6-develop.6402.1.080aab8ec9",
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
- const MESSAGE_PREFIX = 'SEO helper: '
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 === 1) return `${MESSAGE_PREFIX}${issues[0]}`
12
- if (issues.length <= 3) return `${MESSAGE_PREFIX}${issues.join(' ')}`
13
- return `${MESSAGE_PREFIX}${issues.length} issues found. ${issues.slice(0, 2).join(' ')}`
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(data: Record<string, unknown> | null | undefined): ProductSeoEvaluation {
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 type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
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
- await em.transactional(async (trx) => {
177
- // The weekly rules of a rule set are a sub-resource of that rule set: the
178
- // parent is the optimistic-lock consistency boundary. Guard the parent's
179
- // version (so a stale weekly save loses to a concurrent rule-set
180
- // change/delete) and bump its `updated_at` after the replace (so a
181
- // concurrent rule-set delete/update with a stale token conflicts). See #2927.
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
- ruleSets,
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,