@open-mercato/core 0.6.5-develop.4588.1.ecaa16cfc0 → 0.6.5-develop.4620.1.c20bc7e4bb

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/helpers/integration/crudFormFields.js +33 -0
  3. package/dist/helpers/integration/crudFormFields.js.map +7 -0
  4. package/dist/helpers/integration/crudFormPersistence.js +105 -0
  5. package/dist/helpers/integration/crudFormPersistence.js.map +7 -0
  6. package/dist/helpers/integration/undoHarness.js +81 -0
  7. package/dist/helpers/integration/undoHarness.js.map +7 -0
  8. package/dist/modules/customers/components/formConfig.js +22 -7
  9. package/dist/modules/customers/components/formConfig.js.map +2 -2
  10. package/dist/modules/customers/data/validators.js +17 -4
  11. package/dist/modules/customers/data/validators.js.map +2 -2
  12. package/dist/modules/dictionaries/commands/entry-operations.js +8 -0
  13. package/dist/modules/dictionaries/commands/entry-operations.js.map +2 -2
  14. package/dist/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.js +6 -2
  15. package/dist/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.js.map +2 -2
  16. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +9 -1
  17. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  18. package/package.json +7 -7
  19. package/src/helpers/integration/crudFormFields.ts +48 -0
  20. package/src/helpers/integration/crudFormPersistence.ts +166 -0
  21. package/src/helpers/integration/undoHarness.ts +111 -0
  22. package/src/modules/customers/components/formConfig.tsx +59 -25
  23. package/src/modules/customers/data/validators.ts +25 -9
  24. package/src/modules/dictionaries/commands/entry-operations.ts +19 -0
  25. package/src/modules/feature_toggles/backend/feature-toggles/global/[id]/edit/page.tsx +23 -2
  26. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +9 -1
@@ -0,0 +1,48 @@
1
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean';
2
+
3
+ /**
4
+ * Pure, runner-agnostic helpers for the CrudForm field-persistence sweep (umbrella #2466).
5
+ *
6
+ * Kept free of any `@playwright/test` import so this logic is unit-testable under jest.
7
+ * The Playwright harness (`crudFormPersistence.ts`) re-exports everything here.
8
+ */
9
+
10
+ export const CRUDFORM_EXTENSION_TESTS_DISABLED_ENV = 'OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED';
11
+
12
+ export type CrudRecord = Record<string, unknown>;
13
+
14
+ /**
15
+ * Reads the sweep disable flag. Default `false` so the sweep runs unless explicitly turned off
16
+ * via `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED=1` (or `true`/`yes`/`on`).
17
+ */
18
+ export function crudFormExtensionTestsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
19
+ return parseBooleanWithDefault(env[CRUDFORM_EXTENSION_TESTS_DISABLED_ENV], false);
20
+ }
21
+
22
+ /**
23
+ * Resolves a custom-field value from a CRUD response record, tolerating every shape the
24
+ * platform emits: bare keys under `customValues`, top-level `cf_<name>` / `cf:<name>`, or a
25
+ * `customFields` definition array carrying `value`. Returns `undefined` when absent.
26
+ */
27
+ export function getCustomFieldValue(record: CrudRecord, fieldName: string): unknown {
28
+ const customValues = record.customValues;
29
+ if (customValues && typeof customValues === 'object' && fieldName in (customValues as CrudRecord)) {
30
+ return (customValues as CrudRecord)[fieldName];
31
+ }
32
+ const prefixedUnderscore = record[`cf_${fieldName}`];
33
+ if (prefixedUnderscore !== undefined) return prefixedUnderscore;
34
+ const prefixedColon = record[`cf:${fieldName}`];
35
+ if (prefixedColon !== undefined) return prefixedColon;
36
+ const customFields = record.customFields;
37
+ if (Array.isArray(customFields)) {
38
+ const match = customFields.find((entry) => {
39
+ if (!entry || typeof entry !== 'object') return false;
40
+ const candidate = entry as CrudRecord;
41
+ return candidate.key === fieldName || candidate.id === fieldName || candidate.name === fieldName;
42
+ });
43
+ if (match && typeof match === 'object') {
44
+ return (match as CrudRecord).value;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
@@ -0,0 +1,166 @@
1
+ import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test';
2
+ import { apiRequest } from './api';
3
+ import { expectId, readJsonSafe } from './generalFixtures';
4
+ import {
5
+ CRUDFORM_EXTENSION_TESTS_DISABLED_ENV,
6
+ crudFormExtensionTestsDisabled,
7
+ getCustomFieldValue,
8
+ type CrudRecord,
9
+ } from './crudFormFields';
10
+
11
+ /**
12
+ * Playwright harness for the CrudForm field-persistence sweep (umbrella #2466).
13
+ *
14
+ * Every spec proves a CrudForm surface saves AND reloads every field type — scalars,
15
+ * dictionary references, multiselect/array values, and **custom fields** — on both create
16
+ * and update.
17
+ *
18
+ * The whole sweep is gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED`
19
+ * (default `false` → tests run). When set truthy the specs `test.skip()` themselves, so the
20
+ * sweep can be disabled wholesale without deleting any spec.
21
+ */
22
+
23
+ export {
24
+ CRUDFORM_EXTENSION_TESTS_DISABLED_ENV,
25
+ crudFormExtensionTestsDisabled,
26
+ getCustomFieldValue,
27
+ type CrudRecord,
28
+ };
29
+
30
+ /**
31
+ * Call inside `test.beforeAll` (or a test body) to skip the spec when the sweep is disabled.
32
+ * Uses Playwright's `test.skip(condition, reason)` so the spec is reported as skipped, not failed.
33
+ */
34
+ export function skipIfCrudFormExtensionTestsDisabled(): void {
35
+ test.skip(
36
+ crudFormExtensionTestsDisabled(),
37
+ `${CRUDFORM_EXTENSION_TESTS_DISABLED_ENV} is set — CrudForm field-persistence sweep skipped`,
38
+ );
39
+ }
40
+
41
+ /** Asserts each expected scalar field round-tripped (deep equality so arrays/objects work). */
42
+ export function assertScalarFieldsPersisted(record: CrudRecord, expected: CrudRecord, label = 'record'): void {
43
+ for (const [key, value] of Object.entries(expected)) {
44
+ expect(record[key], `${label}.${key} should persist`).toEqual(value);
45
+ }
46
+ }
47
+
48
+ /** Asserts each expected custom field round-tripped, regardless of response shape. */
49
+ export function assertCustomFieldsPersisted(record: CrudRecord, expected: CrudRecord, label = 'record'): void {
50
+ for (const [name, value] of Object.entries(expected)) {
51
+ expect(getCustomFieldValue(record, name), `${label} custom field "${name}" should persist`).toEqual(value);
52
+ }
53
+ }
54
+
55
+ async function safeText(response: APIResponse): Promise<string> {
56
+ try {
57
+ return (await response.text()).slice(0, 500);
58
+ } catch {
59
+ return '<unreadable body>';
60
+ }
61
+ }
62
+
63
+ export type CrudFormExpectation = {
64
+ scalars?: CrudRecord;
65
+ customFields?: CrudRecord;
66
+ };
67
+
68
+ export type CrudFormRoundTripConfig = {
69
+ request: APIRequestContext;
70
+ token: string;
71
+ /** Collection route handling POST/PUT/DELETE and a `?id=` list GET, e.g. `/api/currencies/currencies`. */
72
+ collectionPath: string;
73
+ create: { payload: CrudRecord; expectedStatus?: number };
74
+ /** Build the PUT body from the created id. MUST include the id the route expects. */
75
+ update: { payload: (id: string) => CrudRecord; expectedStatus?: number };
76
+ expectAfterCreate: CrudFormExpectation;
77
+ expectAfterUpdate: CrudFormExpectation;
78
+ /** Override id extraction from the create response (default: `id` ?? `entityId`). */
79
+ idFromCreate?: (body: CrudRecord) => string;
80
+ /** Override read-back (default: list `?id=` and match on `id`). Useful for detail-GET routes. */
81
+ readById?: (id: string) => Promise<CrudRecord | null>;
82
+ /** Delete the fixture in `finally` (default true). */
83
+ cleanup?: boolean;
84
+ };
85
+
86
+ async function defaultReadById(
87
+ request: APIRequestContext,
88
+ token: string,
89
+ collectionPath: string,
90
+ id: string,
91
+ ): Promise<CrudRecord | null> {
92
+ const separator = collectionPath.includes('?') ? '&' : '?';
93
+ const response = await apiRequest(
94
+ request,
95
+ 'GET',
96
+ `${collectionPath}${separator}id=${encodeURIComponent(id)}&page=1&pageSize=100`,
97
+ { token },
98
+ );
99
+ expect(response.status(), `read-back ${collectionPath} failed: ${response.status()}`).toBe(200);
100
+ const body = await readJsonSafe<{ items?: CrudRecord[] } & CrudRecord>(response);
101
+ if (Array.isArray(body?.items)) {
102
+ return body!.items.find((item) => item.id === id) ?? null;
103
+ }
104
+ // Detail routes may return the record directly.
105
+ return body && body.id === id ? (body as CrudRecord) : null;
106
+ }
107
+
108
+ /**
109
+ * Runs the canonical create → read-back → assert → update → read-back → assert → delete
110
+ * cycle for a makeCrud collection route and asserts every declared field persisted.
111
+ */
112
+ export async function runCrudFormRoundTrip(config: CrudFormRoundTripConfig): Promise<void> {
113
+ const { request, token, collectionPath } = config;
114
+ const read = config.readById ?? ((id: string) => defaultReadById(request, token, collectionPath, id));
115
+ let id: string | null = null;
116
+
117
+ try {
118
+ const createResponse = await apiRequest(request, 'POST', collectionPath, {
119
+ token,
120
+ data: config.create.payload,
121
+ });
122
+ expect(
123
+ createResponse.status(),
124
+ `create ${collectionPath} failed (${createResponse.status()}): ${await safeText(createResponse)}`,
125
+ ).toBe(config.create.expectedStatus ?? 201);
126
+ const createBody = (await readJsonSafe<CrudRecord>(createResponse)) ?? {};
127
+ const rawId = config.idFromCreate
128
+ ? config.idFromCreate(createBody)
129
+ : (createBody.id ?? createBody.entityId);
130
+ id = expectId(rawId, `create response should include an id (${collectionPath})`);
131
+
132
+ const afterCreate = await read(id);
133
+ expect(afterCreate, `created record ${id} should be readable from ${collectionPath}`).toBeTruthy();
134
+ if (config.expectAfterCreate.scalars) {
135
+ assertScalarFieldsPersisted(afterCreate as CrudRecord, config.expectAfterCreate.scalars, 'after-create');
136
+ }
137
+ if (config.expectAfterCreate.customFields) {
138
+ assertCustomFieldsPersisted(afterCreate as CrudRecord, config.expectAfterCreate.customFields, 'after-create');
139
+ }
140
+
141
+ const updateResponse = await apiRequest(request, 'PUT', collectionPath, {
142
+ token,
143
+ data: config.update.payload(id),
144
+ });
145
+ expect(
146
+ updateResponse.status(),
147
+ `update ${collectionPath} failed (${updateResponse.status()}): ${await safeText(updateResponse)}`,
148
+ ).toBe(config.update.expectedStatus ?? 200);
149
+
150
+ const afterUpdate = await read(id);
151
+ expect(afterUpdate, `updated record ${id} should be readable from ${collectionPath}`).toBeTruthy();
152
+ if (config.expectAfterUpdate.scalars) {
153
+ assertScalarFieldsPersisted(afterUpdate as CrudRecord, config.expectAfterUpdate.scalars, 'after-update');
154
+ }
155
+ if (config.expectAfterUpdate.customFields) {
156
+ assertCustomFieldsPersisted(afterUpdate as CrudRecord, config.expectAfterUpdate.customFields, 'after-update');
157
+ }
158
+ } finally {
159
+ if (id && config.cleanup !== false) {
160
+ const separator = collectionPath.includes('?') ? '&' : '?';
161
+ await apiRequest(request, 'DELETE', `${collectionPath}${separator}id=${encodeURIComponent(id)}`, {
162
+ token,
163
+ }).catch(() => undefined);
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,111 @@
1
+ import { type APIRequestContext, type APIResponse, expect } from '@playwright/test'
2
+ import { apiRequest } from './api'
3
+
4
+ /**
5
+ * Shared harness for verifying Undo/Redo correctness against the real command bus.
6
+ *
7
+ * Every mutating Open Mercato API response carries the operation metadata in the
8
+ * `x-om-operation` header (`omop:<urlencoded JSON>`) containing the `undoToken` and the
9
+ * audit log `id` (used as `logId` for redo). These helpers extract that envelope and drive
10
+ * the real undo/redo endpoints so tests can assert full state restoration per TC-UNDO-001.
11
+ */
12
+
13
+ const HEADER_PREFIX = 'omop:'
14
+ const UNDO_PATH = '/api/audit_logs/audit-logs/actions/undo'
15
+ const REDO_PATH = '/api/audit_logs/audit-logs/actions/redo'
16
+ const ACTIONS_PATH = '/api/audit_logs/audit-logs/actions'
17
+
18
+ export type Operation = {
19
+ logId: string
20
+ undoToken: string
21
+ commandId: string
22
+ resourceKind: string | null
23
+ resourceId: string | null
24
+ }
25
+
26
+ /** Parse the `x-om-operation` header into a structured operation, or null when absent/malformed. */
27
+ export function extractOperation(response: APIResponse): Operation | null {
28
+ const header = response.headers()['x-om-operation']
29
+ if (!header || typeof header !== 'string') return null
30
+ const trimmed = header.startsWith(HEADER_PREFIX) ? header.slice(HEADER_PREFIX.length) : header
31
+ try {
32
+ const parsed = JSON.parse(decodeURIComponent(trimmed)) as Record<string, unknown>
33
+ if (typeof parsed.id !== 'string' || typeof parsed.commandId !== 'string') return null
34
+ if (typeof parsed.undoToken !== 'string' || !parsed.undoToken) return null
35
+ return {
36
+ logId: parsed.id,
37
+ undoToken: parsed.undoToken,
38
+ commandId: parsed.commandId,
39
+ resourceKind: (parsed.resourceKind as string) ?? null,
40
+ resourceId: (parsed.resourceId as string) ?? null,
41
+ }
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /** Like extractOperation but fails the test if no undo token was issued. */
48
+ export function expectOperation(response: APIResponse, context: string): Operation {
49
+ const op = extractOperation(response)
50
+ expect(op, `Expected an undo token (x-om-operation header) for ${context}, got none`).toBeTruthy()
51
+ return op as Operation
52
+ }
53
+
54
+ export async function undoByToken(request: APIRequestContext, token: string, undoToken: string): Promise<APIResponse> {
55
+ return apiRequest(request, 'POST', UNDO_PATH, { token, data: { undoToken } })
56
+ }
57
+
58
+ export async function redoByLogId(request: APIRequestContext, token: string, logId: string): Promise<APIResponse> {
59
+ return apiRequest(request, 'POST', REDO_PATH, { token, data: { logId } })
60
+ }
61
+
62
+ /** Undo and assert success; returns the resolved logId. */
63
+ export async function undoOk(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<string> {
64
+ const res = await undoByToken(request, token, undoToken)
65
+ const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string } | null
66
+ expect(res.ok(), `Undo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()
67
+ expect(body?.ok, `Undo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()
68
+ return body?.logId as string
69
+ }
70
+
71
+ /** Redo and assert success; returns the new operation (new undoToken + logId). */
72
+ export async function redoOk(request: APIRequestContext, token: string, logId: string, context: string): Promise<{ logId: string; undoToken: string | null }> {
73
+ const res = await redoByLogId(request, token, logId)
74
+ const body = (await res.json().catch(() => null)) as { ok?: boolean; logId?: string; undoToken?: string } | null
75
+ expect(res.ok(), `Redo failed for ${context}: status ${res.status()} body ${JSON.stringify(body)}`).toBeTruthy()
76
+ expect(body?.ok, `Redo not ok for ${context}: ${JSON.stringify(body)}`).toBeTruthy()
77
+ return { logId: body?.logId as string, undoToken: body?.undoToken ?? null }
78
+ }
79
+
80
+ /** Assert that undoing an already-consumed token is rejected (token consumption / no double-undo). */
81
+ export async function expectTokenConsumed(request: APIRequestContext, token: string, undoToken: string, context: string): Promise<void> {
82
+ const res = await undoByToken(request, token, undoToken)
83
+ expect(res.ok(), `Expected double-undo to be rejected for ${context}, but it succeeded`).toBeFalsy()
84
+ }
85
+
86
+ /** Fetch undoable actions list (for Version History assertions). */
87
+ export async function listUndoable(request: APIRequestContext, token: string, params: Record<string, string> = {}): Promise<unknown> {
88
+ const qs = new URLSearchParams({ undoableOnly: 'true', ...params }).toString()
89
+ const res = await apiRequest(request, 'GET', `${ACTIONS_PATH}?${qs}`, { token })
90
+ return res.json().catch(() => null)
91
+ }
92
+
93
+ /**
94
+ * Deep-equality assertion for a selected set of fields between two entity snapshots.
95
+ * Reports the first mismatching field with context for clear bug triage.
96
+ */
97
+ export function assertFieldsEqual(
98
+ actual: Record<string, unknown> | null | undefined,
99
+ expected: Record<string, unknown> | null | undefined,
100
+ fields: string[],
101
+ context: string,
102
+ ): void {
103
+ expect(actual, `${context}: actual entity missing`).toBeTruthy()
104
+ expect(expected, `${context}: expected entity missing`).toBeTruthy()
105
+ for (const field of fields) {
106
+ expect(
107
+ JSON.stringify((actual as Record<string, unknown>)[field]),
108
+ `${context}: field "${field}" not restored (expected ${JSON.stringify((expected as Record<string, unknown>)[field])}, got ${JSON.stringify((actual as Record<string, unknown>)[field])})`,
109
+ ).toBe(JSON.stringify((expected as Record<string, unknown>)[field]))
110
+ }
111
+ }
@@ -1427,15 +1427,20 @@ export function buildCompanyPayload(
1427
1427
  // Edit-mode types
1428
1428
  // ---------------------------------------------------------------------------
1429
1429
 
1430
- export type CompanyEditFormValues = Omit<CompanyFormValues, 'addresses'> & {
1430
+ // URL/email fields are clearable on edit: blanking a previously-set value transmits null,
1431
+ // so the edit-form value types widen to `string | null` to match the edit-schema output. See #2526.
1432
+ export type CompanyEditFormValues = Omit<CompanyFormValues, 'addresses' | 'primaryEmail' | 'websiteUrl'> & {
1431
1433
  id: string
1434
+ primaryEmail?: string | null
1435
+ websiteUrl?: string | null
1432
1436
  }
1433
1437
 
1434
- export type PersonEditFormValues = Omit<PersonFormValues, 'addresses'> & {
1438
+ export type PersonEditFormValues = Omit<PersonFormValues, 'addresses' | 'primaryEmail'> & {
1435
1439
  id: string
1436
1440
  department?: string
1437
- linkedInUrl?: string
1438
- twitterUrl?: string
1441
+ primaryEmail?: string | null
1442
+ linkedInUrl?: string | null
1443
+ twitterUrl?: string | null
1439
1444
  }
1440
1445
 
1441
1446
  // ---------------------------------------------------------------------------
@@ -1451,31 +1456,44 @@ const optionalString = () =>
1451
1456
  .transform((val) => (val === '' ? undefined : val))
1452
1457
  .optional()
1453
1458
 
1459
+ // Edit-mode URL/email fields map to nullable columns and must be clearable: blanking a
1460
+ // previously-set value transforms '' → null so the payload builder can transmit an explicit
1461
+ // clear (omitting the key can never remove an existing value). Create-mode schemas keep the
1462
+ // '' → undefined transform. See #2526.
1463
+ const clearableUrlField = () =>
1464
+ z
1465
+ .string()
1466
+ .trim()
1467
+ .url()
1468
+ .optional()
1469
+ .or(z.literal(''))
1470
+ .transform((val) => (val === '' ? null : val))
1471
+ .optional()
1472
+
1473
+ const clearableEmailField = () =>
1474
+ z
1475
+ .string()
1476
+ .trim()
1477
+ .email()
1478
+ .optional()
1479
+ .or(z.literal(''))
1480
+ .transform((val) => (val === '' ? null : val))
1481
+ .optional()
1482
+
1454
1483
  export const createCompanyEditSchema = () =>
1455
1484
  createCompanyFormSchema().extend({
1456
1485
  id: z.string().uuid(),
1486
+ primaryEmail: clearableEmailField(),
1487
+ websiteUrl: clearableUrlField(),
1457
1488
  })
1458
1489
 
1459
1490
  export const createPersonEditSchema = () =>
1460
1491
  createPersonFormSchema().extend({
1461
1492
  id: z.string().uuid(),
1462
1493
  department: optionalString(),
1463
- linkedInUrl: z
1464
- .string()
1465
- .trim()
1466
- .url()
1467
- .optional()
1468
- .or(z.literal(''))
1469
- .transform((val) => (val === '' ? undefined : val))
1470
- .optional(),
1471
- twitterUrl: z
1472
- .string()
1473
- .trim()
1474
- .url()
1475
- .optional()
1476
- .or(z.literal(''))
1477
- .transform((val) => (val === '' ? undefined : val))
1478
- .optional(),
1494
+ primaryEmail: clearableEmailField(),
1495
+ linkedInUrl: clearableUrlField(),
1496
+ twitterUrl: clearableUrlField(),
1479
1497
  })
1480
1498
 
1481
1499
  // ---------------------------------------------------------------------------
@@ -1755,9 +1773,27 @@ export const createPersonPersonalDataGroups = (
1755
1773
  // Edit-mode payload builders
1756
1774
  // ---------------------------------------------------------------------------
1757
1775
 
1776
+ // On edit, optional URL/email fields that map to nullable columns must transmit an explicit
1777
+ // `null` when the user blanks a previously-set value — omitting the key can never clear it.
1778
+ // The base create-mode builders omit blanks (correct for create), so the edit builders
1779
+ // override these clearable fields here. See #2526.
1780
+ const assignClearable = (payload: Record<string, unknown>, key: string, raw: unknown): void => {
1781
+ if (raw === null) {
1782
+ payload[key] = null
1783
+ return
1784
+ }
1785
+ if (typeof raw !== 'string') return
1786
+ const trimmed = raw.trim()
1787
+ payload[key] = trimmed.length ? trimmed : null
1788
+ }
1789
+
1758
1790
  export function buildCompanyEditPayload(values: CompanyEditFormValues, organizationId?: string | null): Record<string, unknown> {
1759
1791
  const payload = buildCompanyPayload(values, organizationId)
1760
1792
  payload.id = values.id
1793
+
1794
+ assignClearable(payload, 'primaryEmail', values.primaryEmail)
1795
+ assignClearable(payload, 'websiteUrl', values.websiteUrl)
1796
+
1761
1797
  return payload
1762
1798
  }
1763
1799
 
@@ -1768,11 +1804,9 @@ export function buildPersonEditPayload(values: PersonEditFormValues, organizatio
1768
1804
  const department = typeof values.department === 'string' ? values.department.trim() : ''
1769
1805
  if (department.length) payload.department = department
1770
1806
 
1771
- const linkedInUrl = typeof values.linkedInUrl === 'string' ? values.linkedInUrl.trim() : ''
1772
- if (linkedInUrl.length) payload.linkedInUrl = linkedInUrl
1773
-
1774
- const twitterUrl = typeof values.twitterUrl === 'string' ? values.twitterUrl.trim() : ''
1775
- if (twitterUrl.length) payload.twitterUrl = twitterUrl
1807
+ assignClearable(payload, 'primaryEmail', values.primaryEmail)
1808
+ assignClearable(payload, 'linkedInUrl', values.linkedInUrl)
1809
+ assignClearable(payload, 'twitterUrl', values.twitterUrl)
1776
1810
 
1777
1811
  return payload
1778
1812
  }
@@ -14,6 +14,27 @@ const phoneSchema = z.string().trim().max(50).refine((val) => {
14
14
  return isValidPhoneNumber(val)
15
15
  }, { message: CUSTOMER_PHONE_INVALID_MESSAGE_KEY }).optional()
16
16
 
17
+ // Optional URL/email fields map to nullable DB columns. Treat both '' and null as an
18
+ // explicit "clear this value" signal (both coerce to null) so a previously-set value can
19
+ // be removed via update; without this `''` fails `.url()/.email()` and `null` fails the
20
+ // string type, leaving the columns effectively write-once-non-empty. The command layer
21
+ // already persists null. See #2526.
22
+ const emptyStringToNull = (value: unknown): unknown => {
23
+ if (typeof value !== 'string') return value
24
+ const trimmed = value.trim()
25
+ return trimmed.length ? trimmed : null
26
+ }
27
+
28
+ const clearableEmailSchema = z.preprocess(
29
+ emptyStringToNull,
30
+ z.string().email().max(320).nullable().optional(),
31
+ )
32
+
33
+ const clearableUrlSchema = z.preprocess(
34
+ emptyStringToNull,
35
+ z.string().url().max(300).nullable().optional(),
36
+ )
37
+
17
38
  const interactionPhoneNumberSchema = z.string().trim().max(50).optional().nullable()
18
39
 
19
40
  const scopedSchema = z.object({
@@ -42,12 +63,7 @@ const baseEntitySchema = {
42
63
  displayName: displayNameSchema,
43
64
  description: z.string().trim().max(4000).optional(),
44
65
  ownerUserId: uuid().optional(),
45
- primaryEmail: z
46
- .string()
47
- .trim()
48
- .email()
49
- .max(320)
50
- .optional(),
66
+ primaryEmail: clearableEmailSchema,
51
67
  primaryPhone: phoneSchema,
52
68
  status: z.string().trim().max(100).optional(),
53
69
  lifecycleStage: z.string().trim().max(100).optional(),
@@ -65,8 +81,8 @@ const personDetailsSchema = {
65
81
  department: z.string().trim().max(150).optional(),
66
82
  seniority: z.string().trim().max(100).optional(),
67
83
  timezone: z.string().trim().max(120).optional(),
68
- linkedInUrl: z.string().trim().url().max(300).optional(),
69
- twitterUrl: z.string().trim().url().max(300).optional(),
84
+ linkedInUrl: clearableUrlSchema,
85
+ twitterUrl: clearableUrlSchema,
70
86
  companyEntityId: uuid().nullable().optional(),
71
87
  }
72
88
 
@@ -77,7 +93,7 @@ const companyDetailsSchema = {
77
93
  legalName: z.string().trim().max(200).optional(),
78
94
  brandName: z.string().trim().max(200).optional(),
79
95
  domain: z.string().trim().max(200).optional(),
80
- websiteUrl: z.string().trim().url().max(300).optional(),
96
+ websiteUrl: clearableUrlSchema,
81
97
  industry: z.string().trim().max(150).optional(),
82
98
  sizeBucket: z.string().trim().max(100).optional(),
83
99
  annualRevenue: z.coerce.number().min(0).optional(),
@@ -325,6 +325,14 @@ const setDefaultDictionaryEntryCommand: CommandHandler<
325
325
  )
326
326
  const clearedIds: string[] = []
327
327
 
328
+ // The partial unique index `dictionary_entries_one_default_per_dict`
329
+ // (dictionary_id, organization_id, tenant_id) WHERE is_default = true forbids
330
+ // two default entries in the same dictionary. PostgreSQL checks partial unique
331
+ // indexes per-statement (no deferral) and MikroORM does not guarantee that the
332
+ // is_default=false UPDATEs run before the is_default=true UPDATE within a single
333
+ // flush, so clearing and setting in one flush races to a 23505. Clear the prior
334
+ // default(s) in their own flush first, then set the new default in a second
335
+ // flush, so Postgres never observes two default rows at once.
328
336
  await withAtomicFlush(em, [
329
337
  () => {
330
338
  for (const entry of existingDefaults) {
@@ -333,6 +341,10 @@ const setDefaultDictionaryEntryCommand: CommandHandler<
333
341
  entry.updatedAt = new Date()
334
342
  clearedIds.push(entry.id)
335
343
  }
344
+ },
345
+ ], { transaction: true })
346
+ await withAtomicFlush(em, [
347
+ () => {
336
348
  targetEntry.isDefault = true
337
349
  targetEntry.updatedAt = new Date()
338
350
  },
@@ -421,12 +433,19 @@ const setDefaultDictionaryEntryCommand: CommandHandler<
421
433
  )
422
434
  : []
423
435
 
436
+ // Same partial-unique-index ordering constraint as `execute`: clear the
437
+ // current default in its own flush before restoring the previous default(s),
438
+ // so Postgres never sees two is_default=true rows in the dictionary at once.
424
439
  await withAtomicFlush(em, [
425
440
  () => {
426
441
  if (newDefault) {
427
442
  newDefault.isDefault = false
428
443
  newDefault.updatedAt = new Date()
429
444
  }
445
+ },
446
+ ], { transaction: true })
447
+ await withAtomicFlush(em, [
448
+ () => {
430
449
  for (const entry of previousDefaults) {
431
450
  entry.isDefault = true
432
451
  entry.updatedAt = new Date()
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
  import { Page, PageBody } from "@open-mercato/ui/backend/Page";
3
3
  import { CrudForm } from "@open-mercato/ui/backend/CrudForm";
4
+ import { ErrorMessage, LoadingMessage } from "@open-mercato/ui/backend/detail";
4
5
  import { E } from "#generated/entities.ids.generated";
5
6
  import { useT } from "@open-mercato/shared/lib/i18n/context";
6
7
  import * as React from 'react'
@@ -15,7 +16,7 @@ export default function EditFeatureTogglePage({ params }: { params?: { id?: stri
15
16
  const fields = createFieldDefinitions(t);
16
17
  const formGroups = createFormGroups(t);
17
18
 
18
- const { data: featureToggleItem, isLoading } = useFeatureToggleItem(id)
19
+ const { data: featureToggleItem, isLoading, isError } = useFeatureToggleItem(id)
19
20
 
20
21
  // Derive the form's initial values synchronously from the loaded record. Using
21
22
  // a useState+useEffect here caused #2452: `isLoading` flips to false one render
@@ -36,6 +37,26 @@ export default function EditFeatureTogglePage({ params }: { params?: { id?: stri
36
37
  }
37
38
  }, [featureToggleItem, id])
38
39
 
40
+ // Gate the form on load. Mounting CrudForm with placeholder `{}` before the
41
+ // record arrives makes the required `type` Select mount controlled with `''`,
42
+ // then flip empty→value asynchronously — a transition the Radix trigger fails
43
+ // to re-derive, leaving Type blank and blocking save. Rendering CrudForm only
44
+ // once the record is present mirrors the synchronous create flow, so the
45
+ // Select mounts a single time with the stored value already set.
46
+ if (!initialValues) {
47
+ return (
48
+ <Page>
49
+ <PageBody>
50
+ {isError && !isLoading ? (
51
+ <ErrorMessage label={t('feature_toggles.form.errors.load', 'Failed to load feature toggle')} />
52
+ ) : (
53
+ <LoadingMessage label={t('feature_toggles.form.loading', 'Loading feature toggles')} />
54
+ )}
55
+ </PageBody>
56
+ </Page>
57
+ )
58
+ }
59
+
39
60
  return (
40
61
  <Page>
41
62
  <PageBody>
@@ -46,7 +67,7 @@ export default function EditFeatureTogglePage({ params }: { params?: { id?: stri
46
67
  versionHistory={{ resourceKind: 'feature_toggles.global', resourceId: id ? String(id) : '' }}
47
68
  fields={fields}
48
69
  entityId={E.feature_toggles.feature_toggle}
49
- initialValues={initialValues ?? {}}
70
+ initialValues={initialValues}
50
71
  optimisticLockUpdatedAt={featureToggleItem?.updatedAt ?? null}
51
72
  isLoading={isLoading}
52
73
  groups={formGroups}
@@ -69,7 +69,15 @@ export async function PUT(req: Request, ctx: { params?: { entityType?: string; e
69
69
  entityId: ctx.params?.entityId,
70
70
  })
71
71
 
72
- const rawBody = await req.json().catch(() => ({}))
72
+ const rawText = await req.text()
73
+ let rawBody: unknown = {}
74
+ if (rawText.trim().length > 0) {
75
+ try {
76
+ rawBody = JSON.parse(rawText)
77
+ } catch {
78
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
79
+ }
80
+ }
73
81
  const translations = translationBodySchema.parse(rawBody)
74
82
 
75
83
  const commandBus = context.container.resolve('commandBus') as CommandBus