@open-mercato/core 0.6.6-develop.6286.1.d985c5c374 → 0.6.6-develop.6296.1.5e8bdfee17

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 (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/components/products/ProductComplianceSection.js +235 -62
  3. package/dist/modules/catalog/components/products/ProductComplianceSection.js.map +2 -2
  4. package/dist/modules/configs/components/CachePanel.js +2 -1
  5. package/dist/modules/configs/components/CachePanel.js.map +2 -2
  6. package/dist/modules/configs/components/SystemStatusPanel.js +10 -12
  7. package/dist/modules/configs/components/SystemStatusPanel.js.map +2 -2
  8. package/dist/modules/data_sync/workers/sync-scheduled.js +13 -14
  9. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  10. package/dist/modules/directory/components/TenantSelect.js +29 -16
  11. package/dist/modules/directory/components/TenantSelect.js.map +2 -2
  12. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js +47 -1
  13. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js.map +2 -2
  14. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js +35 -0
  15. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js.map +7 -0
  16. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +18 -19
  17. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  18. package/package.json +7 -7
  19. package/src/modules/catalog/components/products/ProductComplianceSection.tsx +138 -27
  20. package/src/modules/configs/components/CachePanel.tsx +2 -3
  21. package/src/modules/configs/components/SystemStatusPanel.tsx +12 -19
  22. package/src/modules/data_sync/workers/sync-scheduled.ts +13 -14
  23. package/src/modules/directory/components/TenantSelect.tsx +33 -16
  24. package/src/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.ts +54 -1
  25. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.ts +36 -0
  26. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +22 -23
@@ -29,9 +29,26 @@ type ProductComplianceSectionProps = {
29
29
  embedded?: boolean;
30
30
  };
31
31
 
32
- function FieldError({ message }: { message?: string }) {
32
+ function fieldErrorId(inputId: string) {
33
+ return `${inputId}-error`;
34
+ }
35
+
36
+ function describedByError(
37
+ inputId: string,
38
+ message?: string,
39
+ ): { "aria-invalid"?: true; "aria-describedby"?: string } {
40
+ return message
41
+ ? { "aria-invalid": true, "aria-describedby": fieldErrorId(inputId) }
42
+ : {};
43
+ }
44
+
45
+ function FieldError({ id, message }: { id: string; message?: string }) {
33
46
  if (!message) return null;
34
- return <p className="text-xs text-destructive">{message}</p>;
47
+ return (
48
+ <p id={id} className="text-xs text-destructive">
49
+ {message}
50
+ </p>
51
+ );
35
52
  }
36
53
 
37
54
  export function ProductComplianceSection({
@@ -85,8 +102,12 @@ export function ProductComplianceSection({
85
102
  onChange={(event) =>
86
103
  setValue("countryOfOriginCode", event.target.value.toUpperCase())
87
104
  }
105
+ {...describedByError("catalog-product-compliance-country", errors.countryOfOriginCode)}
106
+ />
107
+ <FieldError
108
+ id={fieldErrorId("catalog-product-compliance-country")}
109
+ message={errors.countryOfOriginCode}
88
110
  />
89
- <FieldError message={errors.countryOfOriginCode} />
90
111
  </div>
91
112
  <div className="space-y-2">
92
113
  <Label htmlFor="catalog-product-compliance-tax-classification">
@@ -96,8 +117,12 @@ export function ProductComplianceSection({
96
117
  id="catalog-product-compliance-tax-classification"
97
118
  value={values.taxClassificationCode ?? ""}
98
119
  onChange={(event) => setValue("taxClassificationCode", event.target.value)}
120
+ {...describedByError("catalog-product-compliance-tax-classification", errors.taxClassificationCode)}
121
+ />
122
+ <FieldError
123
+ id={fieldErrorId("catalog-product-compliance-tax-classification")}
124
+ message={errors.taxClassificationCode}
99
125
  />
100
- <FieldError message={errors.taxClassificationCode} />
101
126
  </div>
102
127
  <div className="space-y-2">
103
128
  <Label htmlFor="catalog-product-compliance-pkwiu">
@@ -107,8 +132,12 @@ export function ProductComplianceSection({
107
132
  id="catalog-product-compliance-pkwiu"
108
133
  value={values.pkwiuCode ?? ""}
109
134
  onChange={(event) => setValue("pkwiuCode", event.target.value)}
135
+ {...describedByError("catalog-product-compliance-pkwiu", errors.pkwiuCode)}
136
+ />
137
+ <FieldError
138
+ id={fieldErrorId("catalog-product-compliance-pkwiu")}
139
+ message={errors.pkwiuCode}
110
140
  />
111
- <FieldError message={errors.pkwiuCode} />
112
141
  </div>
113
142
  <div className="space-y-2">
114
143
  <Label htmlFor="catalog-product-compliance-cn">
@@ -118,8 +147,12 @@ export function ProductComplianceSection({
118
147
  id="catalog-product-compliance-cn"
119
148
  value={values.cnCode ?? ""}
120
149
  onChange={(event) => setValue("cnCode", event.target.value)}
150
+ {...describedByError("catalog-product-compliance-cn", errors.cnCode)}
151
+ />
152
+ <FieldError
153
+ id={fieldErrorId("catalog-product-compliance-cn")}
154
+ message={errors.cnCode}
121
155
  />
122
- <FieldError message={errors.cnCode} />
123
156
  </div>
124
157
  <div className="space-y-2 md:col-span-2">
125
158
  <Label htmlFor="catalog-product-compliance-hs">
@@ -129,13 +162,24 @@ export function ProductComplianceSection({
129
162
  id="catalog-product-compliance-hs"
130
163
  value={values.hsCode ?? ""}
131
164
  onChange={(event) => setValue("hsCode", event.target.value)}
165
+ {...describedByError("catalog-product-compliance-hs", errors.hsCode)}
166
+ />
167
+ <FieldError
168
+ id={fieldErrorId("catalog-product-compliance-hs")}
169
+ message={errors.hsCode}
132
170
  />
133
- <FieldError message={errors.hsCode} />
134
171
  </div>
135
172
  </div>
136
173
  <div className="space-y-2">
137
- <Label>{t("catalog.products.compliance.fields.gtuCodes", "GTU codes (JPK_V7)")}</Label>
138
- <div className="grid gap-2 sm:grid-cols-3 md:grid-cols-4">
174
+ <Label id="catalog-product-compliance-gtu-label">
175
+ {t("catalog.products.compliance.fields.gtuCodes", "GTU codes (JPK_V7)")}
176
+ </Label>
177
+ <div
178
+ role="group"
179
+ aria-labelledby="catalog-product-compliance-gtu-label"
180
+ className="grid gap-2 sm:grid-cols-3 md:grid-cols-4"
181
+ {...describedByError("catalog-product-compliance-gtu", errors.gtuCodes)}
182
+ >
139
183
  {CATALOG_GTU_CODES.map((code) => (
140
184
  <label
141
185
  key={code}
@@ -151,7 +195,10 @@ export function ProductComplianceSection({
151
195
  </label>
152
196
  ))}
153
197
  </div>
154
- <FieldError message={errors.gtuCodes} />
198
+ <FieldError
199
+ id={fieldErrorId("catalog-product-compliance-gtu")}
200
+ message={errors.gtuCodes}
201
+ />
155
202
  </div>
156
203
  </div>
157
204
 
@@ -179,8 +226,12 @@ export function ProductComplianceSection({
179
226
  max={120}
180
227
  value={values.ageMin ?? ""}
181
228
  onChange={(event) => setValue("ageMin", event.target.value)}
229
+ {...describedByError("catalog-product-compliance-age-min", errors.ageMin)}
230
+ />
231
+ <FieldError
232
+ id={fieldErrorId("catalog-product-compliance-age-min")}
233
+ message={errors.ageMin}
182
234
  />
183
- <FieldError message={errors.ageMin} />
184
235
  </div>
185
236
  <div className="space-y-2">
186
237
  <Label htmlFor="catalog-product-compliance-excise-category">
@@ -192,7 +243,10 @@ export function ProductComplianceSection({
192
243
  setValue("exciseCategory", value === NONE_OPTION ? null : value)
193
244
  }
194
245
  >
195
- <SelectTrigger id="catalog-product-compliance-excise-category">
246
+ <SelectTrigger
247
+ id="catalog-product-compliance-excise-category"
248
+ {...describedByError("catalog-product-compliance-excise-category", errors.exciseCategory)}
249
+ >
196
250
  <SelectValue />
197
251
  </SelectTrigger>
198
252
  <SelectContent>
@@ -206,7 +260,10 @@ export function ProductComplianceSection({
206
260
  ))}
207
261
  </SelectContent>
208
262
  </Select>
209
- <FieldError message={errors.exciseCategory} />
263
+ <FieldError
264
+ id={fieldErrorId("catalog-product-compliance-excise-category")}
265
+ message={errors.exciseCategory}
266
+ />
210
267
  </div>
211
268
  </div>
212
269
  <div className="grid gap-2 sm:grid-cols-2">
@@ -253,8 +310,12 @@ export function ProductComplianceSection({
253
310
  id="catalog-product-compliance-hazmat-class"
254
311
  value={values.hazmatClass ?? ""}
255
312
  onChange={(event) => setValue("hazmatClass", event.target.value)}
313
+ {...describedByError("catalog-product-compliance-hazmat-class", errors.hazmatClass)}
314
+ />
315
+ <FieldError
316
+ id={fieldErrorId("catalog-product-compliance-hazmat-class")}
317
+ message={errors.hazmatClass}
256
318
  />
257
- <FieldError message={errors.hazmatClass} />
258
319
  </div>
259
320
  <div className="space-y-2">
260
321
  <Label htmlFor="catalog-product-compliance-un-number">
@@ -265,8 +326,12 @@ export function ProductComplianceSection({
265
326
  placeholder={t("catalog.products.compliance.placeholders.unNumber", "UN1234")}
266
327
  value={values.unNumber ?? ""}
267
328
  onChange={(event) => setValue("unNumber", event.target.value)}
329
+ {...describedByError("catalog-product-compliance-un-number", errors.unNumber)}
330
+ />
331
+ <FieldError
332
+ id={fieldErrorId("catalog-product-compliance-un-number")}
333
+ message={errors.unNumber}
268
334
  />
269
- <FieldError message={errors.unNumber} />
270
335
  </div>
271
336
  <div className="space-y-2">
272
337
  <Label htmlFor="catalog-product-compliance-packing-group">
@@ -278,7 +343,10 @@ export function ProductComplianceSection({
278
343
  setValue("hazmatPackingGroup", value === NONE_OPTION ? null : value)
279
344
  }
280
345
  >
281
- <SelectTrigger id="catalog-product-compliance-packing-group">
346
+ <SelectTrigger
347
+ id="catalog-product-compliance-packing-group"
348
+ {...describedByError("catalog-product-compliance-packing-group", errors.hazmatPackingGroup)}
349
+ >
282
350
  <SelectValue />
283
351
  </SelectTrigger>
284
352
  <SelectContent>
@@ -292,7 +360,10 @@ export function ProductComplianceSection({
292
360
  ))}
293
361
  </SelectContent>
294
362
  </Select>
295
- <FieldError message={errors.hazmatPackingGroup} />
363
+ <FieldError
364
+ id={fieldErrorId("catalog-product-compliance-packing-group")}
365
+ message={errors.hazmatPackingGroup}
366
+ />
296
367
  </div>
297
368
  </div>
298
369
  </div>
@@ -319,8 +390,12 @@ export function ProductComplianceSection({
319
390
  type="date"
320
391
  value={values.launchAt ?? ""}
321
392
  onChange={(event) => setValue("launchAt", event.target.value)}
393
+ {...describedByError("catalog-product-compliance-launch-at", errors.launchAt)}
394
+ />
395
+ <FieldError
396
+ id={fieldErrorId("catalog-product-compliance-launch-at")}
397
+ message={errors.launchAt}
322
398
  />
323
- <FieldError message={errors.launchAt} />
324
399
  </div>
325
400
  <div className="space-y-2">
326
401
  <Label htmlFor="catalog-product-compliance-eol-at">
@@ -331,8 +406,12 @@ export function ProductComplianceSection({
331
406
  type="date"
332
407
  value={values.endOfLifeAt ?? ""}
333
408
  onChange={(event) => setValue("endOfLifeAt", event.target.value)}
409
+ {...describedByError("catalog-product-compliance-eol-at", errors.endOfLifeAt)}
410
+ />
411
+ <FieldError
412
+ id={fieldErrorId("catalog-product-compliance-eol-at")}
413
+ message={errors.endOfLifeAt}
334
414
  />
335
- <FieldError message={errors.endOfLifeAt} />
336
415
  </div>
337
416
  <div className="space-y-2">
338
417
  <Label htmlFor="catalog-product-compliance-available-from">
@@ -343,8 +422,12 @@ export function ProductComplianceSection({
343
422
  type="date"
344
423
  value={values.availableFrom ?? ""}
345
424
  onChange={(event) => setValue("availableFrom", event.target.value)}
425
+ {...describedByError("catalog-product-compliance-available-from", errors.availableFrom)}
426
+ />
427
+ <FieldError
428
+ id={fieldErrorId("catalog-product-compliance-available-from")}
429
+ message={errors.availableFrom}
346
430
  />
347
- <FieldError message={errors.availableFrom} />
348
431
  </div>
349
432
  <div className="space-y-2">
350
433
  <Label htmlFor="catalog-product-compliance-available-until">
@@ -355,8 +438,12 @@ export function ProductComplianceSection({
355
438
  type="date"
356
439
  value={values.availableUntil ?? ""}
357
440
  onChange={(event) => setValue("availableUntil", event.target.value)}
441
+ {...describedByError("catalog-product-compliance-available-until", errors.availableUntil)}
442
+ />
443
+ <FieldError
444
+ id={fieldErrorId("catalog-product-compliance-available-until")}
445
+ message={errors.availableUntil}
358
446
  />
359
- <FieldError message={errors.availableUntil} />
360
447
  </div>
361
448
  </div>
362
449
  <label
@@ -395,8 +482,12 @@ export function ProductComplianceSection({
395
482
  min={1}
396
483
  value={values.minOrderQty ?? ""}
397
484
  onChange={(event) => setValue("minOrderQty", event.target.value)}
485
+ {...describedByError("catalog-product-compliance-min-qty", errors.minOrderQty)}
486
+ />
487
+ <FieldError
488
+ id={fieldErrorId("catalog-product-compliance-min-qty")}
489
+ message={errors.minOrderQty}
398
490
  />
399
- <FieldError message={errors.minOrderQty} />
400
491
  </div>
401
492
  <div className="space-y-2">
402
493
  <Label htmlFor="catalog-product-compliance-max-qty">
@@ -408,8 +499,12 @@ export function ProductComplianceSection({
408
499
  min={1}
409
500
  value={values.maxOrderQty ?? ""}
410
501
  onChange={(event) => setValue("maxOrderQty", event.target.value)}
502
+ {...describedByError("catalog-product-compliance-max-qty", errors.maxOrderQty)}
503
+ />
504
+ <FieldError
505
+ id={fieldErrorId("catalog-product-compliance-max-qty")}
506
+ message={errors.maxOrderQty}
411
507
  />
412
- <FieldError message={errors.maxOrderQty} />
413
508
  </div>
414
509
  <div className="space-y-2">
415
510
  <Label htmlFor="catalog-product-compliance-qty-increment">
@@ -421,8 +516,12 @@ export function ProductComplianceSection({
421
516
  min={1}
422
517
  value={values.orderQtyIncrement ?? ""}
423
518
  onChange={(event) => setValue("orderQtyIncrement", event.target.value)}
519
+ {...describedByError("catalog-product-compliance-qty-increment", errors.orderQtyIncrement)}
520
+ />
521
+ <FieldError
522
+ id={fieldErrorId("catalog-product-compliance-qty-increment")}
523
+ message={errors.orderQtyIncrement}
424
524
  />
425
- <FieldError message={errors.orderQtyIncrement} />
426
525
  </div>
427
526
  </div>
428
527
  <label
@@ -460,8 +559,12 @@ export function ProductComplianceSection({
460
559
  maxLength={255}
461
560
  value={values.seoTitle ?? ""}
462
561
  onChange={(event) => setValue("seoTitle", event.target.value)}
562
+ {...describedByError("catalog-product-compliance-seo-title", errors.seoTitle)}
563
+ />
564
+ <FieldError
565
+ id={fieldErrorId("catalog-product-compliance-seo-title")}
566
+ message={errors.seoTitle}
463
567
  />
464
- <FieldError message={errors.seoTitle} />
465
568
  </div>
466
569
  <div className="space-y-2">
467
570
  <Label htmlFor="catalog-product-compliance-seo-description">
@@ -473,8 +576,12 @@ export function ProductComplianceSection({
473
576
  maxLength={1000}
474
577
  value={values.seoDescription ?? ""}
475
578
  onChange={(event) => setValue("seoDescription", event.target.value)}
579
+ {...describedByError("catalog-product-compliance-seo-description", errors.seoDescription)}
580
+ />
581
+ <FieldError
582
+ id={fieldErrorId("catalog-product-compliance-seo-description")}
583
+ message={errors.seoDescription}
476
584
  />
477
- <FieldError message={errors.seoDescription} />
478
585
  </div>
479
586
  <div className="space-y-2">
480
587
  <Label htmlFor="catalog-product-compliance-canonical-url">
@@ -487,8 +594,12 @@ export function ProductComplianceSection({
487
594
  placeholder="https://"
488
595
  value={values.canonicalUrl ?? ""}
489
596
  onChange={(event) => setValue("canonicalUrl", event.target.value)}
597
+ {...describedByError("catalog-product-compliance-canonical-url", errors.canonicalUrl)}
598
+ />
599
+ <FieldError
600
+ id={fieldErrorId("catalog-product-compliance-canonical-url")}
601
+ message={errors.canonicalUrl}
490
602
  />
491
- <FieldError message={errors.canonicalUrl} />
492
603
  </div>
493
604
  </div>
494
605
  </div>
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
5
5
  import { Button } from '@open-mercato/ui/primitives/button'
6
6
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
7
+ import { ErrorMessage } from '@open-mercato/ui/backend/detail'
7
8
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
8
9
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
10
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -248,9 +249,7 @@ export function CachePanel() {
248
249
  {t('configs.cache.description', 'Inspect cached responses and clear segments when necessary.')}
249
250
  </p>
250
251
  </header>
251
- <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
252
- {state.error}
253
- </div>
252
+ <ErrorMessage label={state.error} />
254
253
  <div className="flex flex-wrap gap-2">
255
254
  <Button variant="outline" type="button" onClick={handleRefresh}>
256
255
  {t('configs.cache.retry', 'Retry')}
@@ -3,6 +3,8 @@
3
3
  import * as React from 'react'
4
4
  import { Button } from '@open-mercato/ui/primitives/button'
5
5
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
6
+ import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge'
7
+ import { ErrorMessage } from '@open-mercato/ui/backend/detail'
6
8
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
7
9
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
10
  import type {
@@ -23,12 +25,12 @@ const STATUS_LABEL_KEYS: Record<SystemStatusState, string> = {
23
25
  unknown: 'configs.systemStatus.state.unknown',
24
26
  }
25
27
 
26
- const STATUS_BADGE_CLASSES: Record<SystemStatusState, string> = {
27
- enabled: 'border border-emerald-300 bg-emerald-50 text-emerald-700',
28
- disabled: 'border border-slate-300 bg-slate-100 text-slate-700',
29
- set: 'border border-blue-300 bg-blue-50 text-blue-700',
30
- unset: 'border border-dashed border-slate-200 text-slate-500',
31
- unknown: 'border border-amber-300 bg-amber-50 text-amber-700',
28
+ const STATUS_BADGE_VARIANTS: StatusMap<SystemStatusState> = {
29
+ enabled: 'success',
30
+ disabled: 'neutral',
31
+ set: 'info',
32
+ unset: 'neutral',
33
+ unknown: 'warning',
32
34
  }
33
35
 
34
36
  const RUNTIME_MODE_LABEL_KEYS: Record<SystemStatusRuntimeMode, string> = {
@@ -84,15 +86,6 @@ function renderEnvAssignment(
84
86
  )
85
87
  }
86
88
 
87
- function StatusBadge({ state }: { state: SystemStatusState }) {
88
- const t = useT()
89
- return (
90
- <span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${STATUS_BADGE_CLASSES[state]}`}>
91
- {t(STATUS_LABEL_KEYS[state])}
92
- </span>
93
- )
94
- }
95
-
96
89
  type FetchState = {
97
90
  loading: boolean
98
91
  error: string | null
@@ -163,9 +156,7 @@ export function SystemStatusPanel() {
163
156
  )}
164
157
  </p>
165
158
  </header>
166
- <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
167
- {state.error}
168
- </div>
159
+ <ErrorMessage label={state.error} />
169
160
  <Button type="button" variant="outline" onClick={() => loadSnapshot().catch(() => {})}>
170
161
  {t('configs.systemStatus.retry', 'Retry')}
171
162
  </Button>
@@ -215,7 +206,9 @@ export function SystemStatusPanel() {
215
206
  <h4 className="text-sm font-semibold">{t(item.labelKey)}</h4>
216
207
  <p className="text-xs text-muted-foreground">{t(item.descriptionKey)}</p>
217
208
  </div>
218
- <StatusBadge state={item.state} />
209
+ <StatusBadge variant={STATUS_BADGE_VARIANTS[item.state]}>
210
+ {t(STATUS_LABEL_KEYS[item.state])}
211
+ </StatusBadge>
219
212
  </div>
220
213
  <dl className="grid gap-3 text-sm sm:grid-cols-2">
221
214
  <div className="space-y-1">
@@ -2,9 +2,10 @@ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
4
  import type { IntegrationStateService } from '../../integrations/lib/state-service'
5
+ import type { ProgressService } from '../../progress/lib/progressService'
5
6
  import type { SyncRunService } from '../lib/sync-run-service'
6
7
  import { SyncSchedule } from '../data/entities'
7
- import { getSyncQueue } from '../lib/queue'
8
+ import { startDataSyncRun } from '../lib/start-run'
8
9
 
9
10
  type ScheduledSyncPayload = {
10
11
  scheduleId: string
@@ -27,6 +28,7 @@ type HandlerContext = JobContext & {
27
28
  export default async function handle(job: QueuedJob<ScheduledSyncPayload>, ctx: HandlerContext): Promise<void> {
28
29
  const em = ctx.resolve<EntityManager>('em')
29
30
  const syncRunService = ctx.resolve<SyncRunService>('dataSyncRunService')
31
+ const progressService = ctx.resolve<ProgressService>('progressService')
30
32
  const integrationStateService = ctx.resolve<IntegrationStateService>('integrationStateService')
31
33
 
32
34
  const schedule = await findOneWithDecryption(
@@ -70,22 +72,19 @@ export default async function handle(job: QueuedJob<ScheduledSyncPayload>, ctx:
70
72
  job.payload.scope,
71
73
  )
72
74
 
73
- const run = await syncRunService.createRun({
74
- integrationId: schedule.integrationId,
75
- entityType: schedule.entityType,
76
- direction: schedule.direction,
77
- cursor,
78
- triggeredBy: 'scheduler',
79
- }, job.payload.scope)
80
-
81
75
  schedule.lastRunAt = new Date()
82
76
  await em.flush()
83
77
 
84
- const queueName = schedule.direction === 'import' ? 'data-sync-import' : 'data-sync-export'
85
- const queue = getSyncQueue(queueName)
86
- await queue.enqueue({
87
- runId: run.id,
88
- batchSize: 100,
78
+ await startDataSyncRun({
79
+ syncRunService,
80
+ progressService,
89
81
  scope: job.payload.scope,
82
+ input: {
83
+ integrationId: schedule.integrationId,
84
+ entityType: schedule.entityType,
85
+ direction: schedule.direction,
86
+ cursor,
87
+ triggeredBy: 'scheduler',
88
+ },
90
89
  })
91
90
  }
@@ -57,21 +57,11 @@ function filterTenantsByStatus(list: TenantRecord[], status: 'all' | 'active' |
57
57
  return list
58
58
  }
59
59
 
60
- async function fetchDirectoryTenants(status: 'all' | 'active' | 'inactive', errorMessage: string): Promise<TenantRecord[]> {
61
- const search = new URLSearchParams()
62
- search.set('page', '1')
63
- search.set('pageSize', '100')
64
- search.set('sortField', 'name')
65
- search.set('sortDir', 'asc')
66
- if (status === 'active') search.set('isActive', 'true')
67
- if (status === 'inactive') search.set('isActive', 'false')
68
- const json = await readApiResultOrThrow<{ items?: unknown[] }>(
69
- `/api/directory/tenants?${search.toString()}`,
70
- undefined,
71
- { errorMessage, allowNullResult: true },
72
- )
73
- const items = Array.isArray(json?.items) ? json.items : []
74
- const normalized = items
60
+ const DIRECTORY_TENANTS_PAGE_SIZE = 100
61
+ const DIRECTORY_TENANTS_MAX_PAGES = 200
62
+
63
+ function normalizeTenantItems(items: unknown[]): TenantRecord[] {
64
+ return items
75
65
  .map((item: unknown): TenantRecord | null => {
76
66
  if (!item || typeof item !== 'object') return null
77
67
  const entry = item as Record<string, unknown>
@@ -84,7 +74,34 @@ async function fetchDirectoryTenants(status: 'all' | 'active' | 'inactive', erro
84
74
  return { id, name, isActive }
85
75
  })
86
76
  .filter((tenant: TenantRecord | null): tenant is TenantRecord => tenant !== null)
87
- return mergeTenantLists(filterTenantsByStatus(normalized, status))
77
+ }
78
+
79
+ async function fetchDirectoryTenants(status: 'all' | 'active' | 'inactive', errorMessage: string): Promise<TenantRecord[]> {
80
+ const collected: TenantRecord[] = []
81
+ let page = 1
82
+ let totalPages = 1
83
+ do {
84
+ const search = new URLSearchParams()
85
+ search.set('page', String(page))
86
+ search.set('pageSize', String(DIRECTORY_TENANTS_PAGE_SIZE))
87
+ search.set('sortField', 'name')
88
+ search.set('sortDir', 'asc')
89
+ if (status === 'active') search.set('isActive', 'true')
90
+ if (status === 'inactive') search.set('isActive', 'false')
91
+ const json = await readApiResultOrThrow<{ items?: unknown[]; totalPages?: unknown }>(
92
+ `/api/directory/tenants?${search.toString()}`,
93
+ undefined,
94
+ { errorMessage, allowNullResult: true },
95
+ )
96
+ const items = Array.isArray(json?.items) ? json.items : []
97
+ collected.push(...normalizeTenantItems(items))
98
+ totalPages = typeof json?.totalPages === 'number' && Number.isFinite(json.totalPages)
99
+ ? Math.max(1, Math.floor(json.totalPages))
100
+ : 1
101
+ if (items.length < DIRECTORY_TENANTS_PAGE_SIZE) break
102
+ page += 1
103
+ } while (page <= totalPages && page <= DIRECTORY_TENANTS_MAX_PAGES)
104
+ return mergeTenantLists(filterTenantsByStatus(collected, status))
88
105
  }
89
106
 
90
107
  async function fetchTenantsFromOrganizationSwitcher(status: 'all' | 'active' | 'inactive', errorMessage: string): Promise<TenantRecord[]> {
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { raw } from '@mikro-orm/core'
2
3
  import { Resend } from 'resend'
3
4
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
5
  import { resolveDefaultEmailFromAddress } from '@open-mercato/shared/lib/email/config'
@@ -111,6 +112,57 @@ export async function POST(req: Request) {
111
112
  headers['References'] = references.join(' ')
112
113
  }
113
114
 
115
+ // Atomically claim the send right before calling the external email provider so
116
+ // two concurrent requests cannot both deliver the same reply. The claim only
117
+ // succeeds when neither replySentAt nor replySendClaimedAt is already set in the
118
+ // metadata; a losing request gets 0 rows back and is rejected with 409.
119
+ const sendClaimedAt = new Date().toISOString()
120
+ const claimed = await ctx.em.nativeUpdate(
121
+ InboxProposalAction,
122
+ {
123
+ id: action.id,
124
+ proposalId: proposal.id,
125
+ actionType: 'draft_reply',
126
+ organizationId: ctx.organizationId,
127
+ tenantId: ctx.tenantId,
128
+ deletedAt: null,
129
+ status: 'executed',
130
+ [raw(`coalesce("metadata"->>'replySentAt', '')`)]: '',
131
+ [raw(`coalesce("metadata"->>'replySendClaimedAt', '')`)]: '',
132
+ },
133
+ {
134
+ metadata: raw(
135
+ `coalesce("metadata", '{}'::jsonb) || jsonb_build_object('replySendClaimedAt', ?::text)`,
136
+ [sendClaimedAt],
137
+ ),
138
+ },
139
+ )
140
+
141
+ if (claimed === 0) {
142
+ return NextResponse.json(
143
+ { error: 'Reply send is already in progress or has been sent for this action' },
144
+ { status: 409 },
145
+ )
146
+ }
147
+
148
+ const releaseSendClaim = async () => {
149
+ try {
150
+ await ctx.em.nativeUpdate(
151
+ InboxProposalAction,
152
+ {
153
+ id: action.id,
154
+ organizationId: ctx.organizationId,
155
+ tenantId: ctx.tenantId,
156
+ [raw(`coalesce("metadata"->>'replySentAt', '')`)]: '',
157
+ [raw(`("metadata"->>'replySendClaimedAt')`)]: sendClaimedAt,
158
+ },
159
+ { metadata: raw(`"metadata" - 'replySendClaimedAt'`) },
160
+ )
161
+ } catch (releaseError) {
162
+ console.error('[inbox_ops:reply:send] Failed to release send claim:', releaseError)
163
+ }
164
+ }
165
+
114
166
  const resend = new Resend(apiKey)
115
167
  const { data: sendData, error: sendError } = await resend.emails.send({
116
168
  to: toAddress,
@@ -121,6 +173,7 @@ export async function POST(req: Request) {
121
173
  })
122
174
 
123
175
  if (sendError) {
176
+ await releaseSendClaim()
124
177
  const errorMessage = sendError.message || 'Unknown error'
125
178
  return NextResponse.json({ error: `Failed to send email: ${errorMessage}` }, { status: 502 })
126
179
  }
@@ -182,7 +235,7 @@ export const openApi: OpenApiRouteDoc = {
182
235
  { status: 200, description: 'Reply sent successfully' },
183
236
  { status: 400, description: 'Missing required payload fields' },
184
237
  { status: 404, description: 'Reply action not found' },
185
- { status: 409, description: 'Action in invalid state for sending' },
238
+ { status: 409, description: 'Action in invalid state for sending, or a send is already in progress / completed for this reply' },
186
239
  { status: 502, description: 'Email delivery failed' },
187
240
  { status: 503, description: 'Email service not configured or disabled' },
188
241
  ],
@@ -0,0 +1,36 @@
1
+ import {
2
+ readVersionedPreference,
3
+ writeVersionedPreference,
4
+ clearVersionedPreference,
5
+ } from '@open-mercato/shared/lib/browser/versionedPreference'
6
+
7
+ const LEGACY_HIDDEN_WIDGETS_KEY = 'om:portal:dashboard:hidden'
8
+ const HIDDEN_WIDGETS_KEY_PREFIX = 'om:portal:dashboard:hidden:v1:'
9
+ const HIDDEN_WIDGETS_VERSION = 1
10
+
11
+ function buildHiddenWidgetsKey(orgSlug: string, userId: string): string {
12
+ return `${HIDDEN_WIDGETS_KEY_PREFIX}${orgSlug}:${userId}`
13
+ }
14
+
15
+ function isStringArray(value: unknown): value is string[] {
16
+ return Array.isArray(value) && value.every((id) => typeof id === 'string')
17
+ }
18
+
19
+ export function loadHiddenWidgets(orgSlug: string, userId: string): Set<string> {
20
+ const hidden = readVersionedPreference<string[]>(
21
+ buildHiddenWidgetsKey(orgSlug, userId),
22
+ HIDDEN_WIDGETS_VERSION,
23
+ isStringArray,
24
+ [],
25
+ )
26
+ return new Set(hidden)
27
+ }
28
+
29
+ export function saveHiddenWidgets(orgSlug: string, userId: string, hidden: Set<string>): void {
30
+ writeVersionedPreference(buildHiddenWidgetsKey(orgSlug, userId), HIDDEN_WIDGETS_VERSION, Array.from(hidden))
31
+ }
32
+
33
+ /** Drops the pre-scoping global key so stale preferences stop leaking across org/user contexts. */
34
+ export function clearLegacyHiddenWidgetsKey(): void {
35
+ clearVersionedPreference(LEGACY_HIDDEN_WIDGETS_KEY)
36
+ }