@open-mercato/core 0.6.6-develop.6290.1.4bb5a8ba3f → 0.6.6-develop.6299.1.29224e2d86

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 (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +8 -0
  3. package/dist/generated/entities/module_config/index.js +4 -0
  4. package/dist/generated/entities/module_config/index.js.map +2 -2
  5. package/dist/generated/entity-fields-registry.js +2 -0
  6. package/dist/generated/entity-fields-registry.js.map +2 -2
  7. package/dist/modules/auth/lib/setup-app.js +1 -1
  8. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  9. package/dist/modules/catalog/components/products/ProductComplianceSection.js +235 -62
  10. package/dist/modules/catalog/components/products/ProductComplianceSection.js.map +2 -2
  11. package/dist/modules/configs/components/CachePanel.js +2 -1
  12. package/dist/modules/configs/components/CachePanel.js.map +2 -2
  13. package/dist/modules/configs/components/SystemStatusPanel.js +10 -12
  14. package/dist/modules/configs/components/SystemStatusPanel.js.map +2 -2
  15. package/dist/modules/configs/data/entities.js +15 -1
  16. package/dist/modules/configs/data/entities.js.map +2 -2
  17. package/dist/modules/configs/lib/module-config-service.js +52 -19
  18. package/dist/modules/configs/lib/module-config-service.js.map +3 -3
  19. package/dist/modules/configs/migrations/Migration20260617150000.js +21 -0
  20. package/dist/modules/configs/migrations/Migration20260617150000.js.map +7 -0
  21. package/dist/modules/customers/api/activities/route.js.map +2 -2
  22. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js +47 -1
  23. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js.map +2 -2
  24. package/generated/entities/module_config/index.ts +2 -0
  25. package/generated/entity-fields-registry.ts +2 -0
  26. package/package.json +7 -7
  27. package/src/modules/auth/lib/setup-app.ts +1 -1
  28. package/src/modules/catalog/components/products/ProductComplianceSection.tsx +138 -27
  29. package/src/modules/configs/components/CachePanel.tsx +2 -3
  30. package/src/modules/configs/components/SystemStatusPanel.tsx +12 -19
  31. package/src/modules/configs/data/entities.ts +17 -1
  32. package/src/modules/configs/lib/module-config-service.ts +83 -24
  33. package/src/modules/configs/migrations/.snapshot-open-mercato.json +41 -4
  34. package/src/modules/configs/migrations/.snapshot-openmercato.json +41 -4
  35. package/src/modules/configs/migrations/Migration20260617150000.ts +21 -0
  36. package/src/modules/customers/api/activities/route.ts +6 -0
  37. package/src/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.ts +54 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../../src/modules/inbox_ops/api/proposals/%5Bid%5D/replies/%5BreplyId%5D/send/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { Resend } from 'resend'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { resolveDefaultEmailFromAddress } from '@open-mercato/shared/lib/email/config'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { InboxProposalAction, InboxEmail } from '../../../../../../data/entities'\nimport { draftReplyPayloadSchema } from '../../../../../../data/validators'\nimport { emitInboxOpsEvent } from '../../../../../../events'\nimport { createMessageRecordForReply } from '../../../../../../lib/messagesIntegration'\nimport {\n resolveRequestContext,\n resolveProposal,\n extractPathSegment,\n handleRouteError,\n isErrorResponse,\n} from '../../../../../routeHelpers'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['inbox_ops.replies.send'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const ctx = await resolveRequestContext(req)\n const url = new URL(req.url)\n const proposal = await resolveProposal(url, ctx)\n if (isErrorResponse(proposal)) return proposal\n\n const replyId = extractPathSegment(url, 'replies')\n if (!replyId) {\n return NextResponse.json({ error: 'Missing reply ID' }, { status: 400 })\n }\n\n const action = await findOneWithDecryption(\n ctx.em,\n InboxProposalAction,\n {\n id: replyId,\n proposalId: proposal.id,\n actionType: 'draft_reply',\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n deletedAt: null,\n },\n undefined,\n ctx.scope,\n )\n\n if (!action) {\n return NextResponse.json({ error: 'Reply action not found' }, { status: 404 })\n }\n\n if (action.status !== 'executed') {\n return NextResponse.json(\n { error: `Cannot send reply \u2014 action must be accepted first (current status: \"${action.status}\")` },\n { status: 409 },\n )\n }\n\n const existingMetadata =\n action.metadata && typeof action.metadata === 'object' ? action.metadata : {}\n if ((existingMetadata as Record<string, unknown>).replySentAt) {\n return NextResponse.json(\n { error: 'Reply has already been sent for this action' },\n { status: 409 },\n )\n }\n\n const email = await findOneWithDecryption(\n ctx.em,\n InboxEmail,\n { id: proposal.inboxEmailId, deletedAt: null },\n undefined,\n ctx.scope,\n )\n\n const payloadResult = draftReplyPayloadSchema.safeParse(action.payload)\n if (!payloadResult.success) {\n return NextResponse.json({ error: 'Reply payload missing required fields (to, subject, body)' }, { status: 400 })\n }\n const { to: toAddress, toName, subject, body } = payloadResult.data\n\n const apiKey = process.env.RESEND_API_KEY\n if (!apiKey) {\n return NextResponse.json({ error: 'Email service not configured' }, { status: 503 })\n }\n\n const emailDisabled =\n parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||\n parseBooleanWithDefault(process.env.OM_TEST_MODE, false)\n if (emailDisabled) {\n return NextResponse.json({ error: 'Email delivery is disabled' }, { status: 503 })\n }\n\n const { inReplyToMessageId, references: payloadReferences } = payloadResult.data\n const fromAddress = resolveDefaultEmailFromAddress()\n if (!fromAddress) {\n return NextResponse.json({\n error: 'Email sender is not configured. Set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL.',\n }, { status: 503 })\n }\n\n const headers: Record<string, string> = {}\n const inReplyTo = inReplyToMessageId || email?.messageId\n if (inReplyTo) {\n headers['In-Reply-To'] = inReplyTo\n }\n const references = payloadReferences || (email?.emailReferences as string[])\n if (references && references.length > 0) {\n headers['References'] = references.join(' ')\n }\n\n const resend = new Resend(apiKey)\n const { data: sendData, error: sendError } = await resend.emails.send({\n to: toAddress,\n from: fromAddress,\n subject,\n text: body,\n ...(Object.keys(headers).length > 0 ? { headers } : {}),\n })\n\n if (sendError) {\n const errorMessage = sendError.message || 'Unknown error'\n return NextResponse.json({ error: `Failed to send email: ${errorMessage}` }, { status: 502 })\n }\n\n const sentMessageId = sendData?.id || null\n const messagesResult = await createMessageRecordForReply(\n { to: toAddress, toName, subject, body },\n proposal.inboxEmailId,\n {\n container: ctx.container,\n scope: {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n },\n },\n )\n\n action.metadata = {\n ...existingMetadata,\n replySentAt: new Date().toISOString(),\n sentMessageId,\n ...(messagesResult ? { messageRecordId: messagesResult.messageId } : {}),\n }\n await ctx.em.flush()\n\n try {\n await emitInboxOpsEvent('inbox_ops.reply.sent', {\n proposalId: proposal.id,\n actionId: replyId,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n toAddress,\n sentMessageId,\n messageRecordId: messagesResult?.messageId ?? null,\n })\n } catch (eventError) {\n console.error('[inbox_ops:reply:send] Failed to emit event:', eventError)\n }\n\n return NextResponse.json({\n ok: true,\n sentMessageId,\n ...(messagesResult ? { messageRecordId: messagesResult.messageId } : {}),\n })\n } catch (err) {\n return handleRouteError(err, 'send reply')\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'InboxOps',\n summary: 'Send draft reply',\n methods: {\n POST: {\n summary: 'Send a draft reply email and register it in messages when available',\n description: 'Sends the draft_reply action payload via the configured email provider. When the messages module is available, also records the sent reply as an internal message record. Sets In-Reply-To and References headers for threading.',\n responses: [\n { status: 200, description: 'Reply sent successfully' },\n { status: 400, description: 'Missing required payload fields' },\n { status: 404, description: 'Reply action not found' },\n { status: 409, description: 'Action in invalid state for sending' },\n { status: 502, description: 'Email delivery failed' },\n { status: 503, description: 'Email service not configured or disabled' },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { raw } from '@mikro-orm/core'\nimport { Resend } from 'resend'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { resolveDefaultEmailFromAddress } from '@open-mercato/shared/lib/email/config'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { InboxProposalAction, InboxEmail } from '../../../../../../data/entities'\nimport { draftReplyPayloadSchema } from '../../../../../../data/validators'\nimport { emitInboxOpsEvent } from '../../../../../../events'\nimport { createMessageRecordForReply } from '../../../../../../lib/messagesIntegration'\nimport {\n resolveRequestContext,\n resolveProposal,\n extractPathSegment,\n handleRouteError,\n isErrorResponse,\n} from '../../../../../routeHelpers'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['inbox_ops.replies.send'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const ctx = await resolveRequestContext(req)\n const url = new URL(req.url)\n const proposal = await resolveProposal(url, ctx)\n if (isErrorResponse(proposal)) return proposal\n\n const replyId = extractPathSegment(url, 'replies')\n if (!replyId) {\n return NextResponse.json({ error: 'Missing reply ID' }, { status: 400 })\n }\n\n const action = await findOneWithDecryption(\n ctx.em,\n InboxProposalAction,\n {\n id: replyId,\n proposalId: proposal.id,\n actionType: 'draft_reply',\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n deletedAt: null,\n },\n undefined,\n ctx.scope,\n )\n\n if (!action) {\n return NextResponse.json({ error: 'Reply action not found' }, { status: 404 })\n }\n\n if (action.status !== 'executed') {\n return NextResponse.json(\n { error: `Cannot send reply \u2014 action must be accepted first (current status: \"${action.status}\")` },\n { status: 409 },\n )\n }\n\n const existingMetadata =\n action.metadata && typeof action.metadata === 'object' ? action.metadata : {}\n if ((existingMetadata as Record<string, unknown>).replySentAt) {\n return NextResponse.json(\n { error: 'Reply has already been sent for this action' },\n { status: 409 },\n )\n }\n\n const email = await findOneWithDecryption(\n ctx.em,\n InboxEmail,\n { id: proposal.inboxEmailId, deletedAt: null },\n undefined,\n ctx.scope,\n )\n\n const payloadResult = draftReplyPayloadSchema.safeParse(action.payload)\n if (!payloadResult.success) {\n return NextResponse.json({ error: 'Reply payload missing required fields (to, subject, body)' }, { status: 400 })\n }\n const { to: toAddress, toName, subject, body } = payloadResult.data\n\n const apiKey = process.env.RESEND_API_KEY\n if (!apiKey) {\n return NextResponse.json({ error: 'Email service not configured' }, { status: 503 })\n }\n\n const emailDisabled =\n parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||\n parseBooleanWithDefault(process.env.OM_TEST_MODE, false)\n if (emailDisabled) {\n return NextResponse.json({ error: 'Email delivery is disabled' }, { status: 503 })\n }\n\n const { inReplyToMessageId, references: payloadReferences } = payloadResult.data\n const fromAddress = resolveDefaultEmailFromAddress()\n if (!fromAddress) {\n return NextResponse.json({\n error: 'Email sender is not configured. Set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL.',\n }, { status: 503 })\n }\n\n const headers: Record<string, string> = {}\n const inReplyTo = inReplyToMessageId || email?.messageId\n if (inReplyTo) {\n headers['In-Reply-To'] = inReplyTo\n }\n const references = payloadReferences || (email?.emailReferences as string[])\n if (references && references.length > 0) {\n headers['References'] = references.join(' ')\n }\n\n // Atomically claim the send right before calling the external email provider so\n // two concurrent requests cannot both deliver the same reply. The claim only\n // succeeds when neither replySentAt nor replySendClaimedAt is already set in the\n // metadata; a losing request gets 0 rows back and is rejected with 409.\n const sendClaimedAt = new Date().toISOString()\n const claimed = await ctx.em.nativeUpdate(\n InboxProposalAction,\n {\n id: action.id,\n proposalId: proposal.id,\n actionType: 'draft_reply',\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n deletedAt: null,\n status: 'executed',\n [raw(`coalesce(\"metadata\"->>'replySentAt', '')`)]: '',\n [raw(`coalesce(\"metadata\"->>'replySendClaimedAt', '')`)]: '',\n },\n {\n metadata: raw(\n `coalesce(\"metadata\", '{}'::jsonb) || jsonb_build_object('replySendClaimedAt', ?::text)`,\n [sendClaimedAt],\n ),\n },\n )\n\n if (claimed === 0) {\n return NextResponse.json(\n { error: 'Reply send is already in progress or has been sent for this action' },\n { status: 409 },\n )\n }\n\n const releaseSendClaim = async () => {\n try {\n await ctx.em.nativeUpdate(\n InboxProposalAction,\n {\n id: action.id,\n organizationId: ctx.organizationId,\n tenantId: ctx.tenantId,\n [raw(`coalesce(\"metadata\"->>'replySentAt', '')`)]: '',\n [raw(`(\"metadata\"->>'replySendClaimedAt')`)]: sendClaimedAt,\n },\n { metadata: raw(`\"metadata\" - 'replySendClaimedAt'`) },\n )\n } catch (releaseError) {\n console.error('[inbox_ops:reply:send] Failed to release send claim:', releaseError)\n }\n }\n\n const resend = new Resend(apiKey)\n const { data: sendData, error: sendError } = await resend.emails.send({\n to: toAddress,\n from: fromAddress,\n subject,\n text: body,\n ...(Object.keys(headers).length > 0 ? { headers } : {}),\n })\n\n if (sendError) {\n await releaseSendClaim()\n const errorMessage = sendError.message || 'Unknown error'\n return NextResponse.json({ error: `Failed to send email: ${errorMessage}` }, { status: 502 })\n }\n\n const sentMessageId = sendData?.id || null\n const messagesResult = await createMessageRecordForReply(\n { to: toAddress, toName, subject, body },\n proposal.inboxEmailId,\n {\n container: ctx.container,\n scope: {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n },\n },\n )\n\n action.metadata = {\n ...existingMetadata,\n replySentAt: new Date().toISOString(),\n sentMessageId,\n ...(messagesResult ? { messageRecordId: messagesResult.messageId } : {}),\n }\n await ctx.em.flush()\n\n try {\n await emitInboxOpsEvent('inbox_ops.reply.sent', {\n proposalId: proposal.id,\n actionId: replyId,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n toAddress,\n sentMessageId,\n messageRecordId: messagesResult?.messageId ?? null,\n })\n } catch (eventError) {\n console.error('[inbox_ops:reply:send] Failed to emit event:', eventError)\n }\n\n return NextResponse.json({\n ok: true,\n sentMessageId,\n ...(messagesResult ? { messageRecordId: messagesResult.messageId } : {}),\n })\n } catch (err) {\n return handleRouteError(err, 'send reply')\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'InboxOps',\n summary: 'Send draft reply',\n methods: {\n POST: {\n summary: 'Send a draft reply email and register it in messages when available',\n description: 'Sends the draft_reply action payload via the configured email provider. When the messages module is available, also records the sent reply as an internal message record. Sets In-Reply-To and References headers for threading.',\n responses: [\n { status: 200, description: 'Reply sent successfully' },\n { status: 400, description: 'Missing required payload fields' },\n { status: 404, description: 'Reply action not found' },\n { status: 409, description: 'Action in invalid state for sending, or a send is already in progress / completed for this reply' },\n { status: 502, description: 'Email delivery failed' },\n { status: 503, description: 'Email service not configured or disabled' },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,5 +2,7 @@ export const id = "id";
2
2
  export const module_id = "module_id";
3
3
  export const name = "name";
4
4
  export const value_json = "value_json";
5
+ export const organization_id = "organization_id";
6
+ export const tenant_id = "tenant_id";
5
7
  export const created_at = "created_at";
6
8
  export const updated_at = "updated_at";
@@ -1541,6 +1541,8 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
1541
1541
  "module_id": "module_id",
1542
1542
  "name": "name",
1543
1543
  "value_json": "value_json",
1544
+ "organization_id": "organization_id",
1545
+ "tenant_id": "tenant_id",
1544
1546
  "created_at": "created_at",
1545
1547
  "updated_at": "updated_at"
1546
1548
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.6290.1.4bb5a8ba3f",
3
+ "version": "0.6.6-develop.6299.1.29224e2d86",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -246,16 +246,16 @@
246
246
  "zod": "^4.4.3"
247
247
  },
248
248
  "peerDependencies": {
249
- "@open-mercato/ai-assistant": "0.6.6-develop.6290.1.4bb5a8ba3f",
250
- "@open-mercato/shared": "0.6.6-develop.6290.1.4bb5a8ba3f",
251
- "@open-mercato/ui": "0.6.6-develop.6290.1.4bb5a8ba3f",
249
+ "@open-mercato/ai-assistant": "0.6.6-develop.6299.1.29224e2d86",
250
+ "@open-mercato/shared": "0.6.6-develop.6299.1.29224e2d86",
251
+ "@open-mercato/ui": "0.6.6-develop.6299.1.29224e2d86",
252
252
  "react": "^19.0.0",
253
253
  "react-dom": "^19.0.0"
254
254
  },
255
255
  "devDependencies": {
256
- "@open-mercato/ai-assistant": "0.6.6-develop.6290.1.4bb5a8ba3f",
257
- "@open-mercato/shared": "0.6.6-develop.6290.1.4bb5a8ba3f",
258
- "@open-mercato/ui": "0.6.6-develop.6290.1.4bb5a8ba3f",
256
+ "@open-mercato/ai-assistant": "0.6.6-develop.6299.1.29224e2d86",
257
+ "@open-mercato/shared": "0.6.6-develop.6299.1.29224e2d86",
258
+ "@open-mercato/ui": "0.6.6-develop.6299.1.29224e2d86",
259
259
  "@testing-library/dom": "^10.4.1",
260
260
  "@testing-library/jest-dom": "^6.9.1",
261
261
  "@testing-library/react": "^16.3.1",
@@ -300,7 +300,7 @@ export async function setupInitialTenant(
300
300
 
301
301
  await em.transactional(async (tem) => {
302
302
  const tenant = tem.create(Tenant, {
303
- name: `${options.orgName} Tenant`,
303
+ name: options.orgName,
304
304
  isActive: true,
305
305
  createdAt: new Date(),
306
306
  updatedAt: new Date(),
@@ -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,7 +2,17 @@ import { OptionalProps } from '@mikro-orm/core'
2
2
  import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
3
3
 
4
4
  @Entity({ tableName: 'module_configs' })
5
- @Unique({ name: 'module_configs_module_name_unique', properties: ['moduleId', 'name'] })
5
+ @Index({
6
+ name: 'module_configs_global_unique',
7
+ expression:
8
+ 'create unique index "module_configs_global_unique" on "module_configs" ("module_id", "name") where "tenant_id" is null',
9
+ })
10
+ @Index({
11
+ name: 'module_configs_scoped_unique',
12
+ expression:
13
+ 'create unique index "module_configs_scoped_unique" on "module_configs" ("module_id", "name", "tenant_id") where "tenant_id" is not null',
14
+ })
15
+ @Index({ name: 'module_configs_module_name_tenant_idx', properties: ['moduleId', 'name', 'tenantId'] })
6
16
  export class ModuleConfig {
7
17
  [OptionalProps]?: 'createdAt' | 'updatedAt'
8
18
 
@@ -18,6 +28,12 @@ export class ModuleConfig {
18
28
  @Property({ name: 'value_json', type: 'json', nullable: true })
19
29
  valueJson!: unknown
20
30
 
31
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
32
+ organizationId?: string | null
33
+
34
+ @Property({ name: 'tenant_id', type: 'uuid', nullable: true })
35
+ tenantId?: string | null
36
+
21
37
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
22
38
  createdAt: Date = new Date()
23
39