fix(admin-ui): отправляем валидный ISO datetime в validFrom/validTo + format=date-time

500 при сохранении записи — backend ждёт OffsetDateTime в DTO CreateRecordRequest,
а DatePicker отдавал просто 'yyyy-MM-dd'. Добавил helpers:
  - formatIsoDateTime(d) → 'yyyy-MM-ddT00:00:00Z'
  - ensureIsoDateTime(s) → нормализует submit-значение validFrom/validTo

Применил:
  - validFrom/validTo на submit нормализуются (если YYYY-MM-DD → +T00:00:00Z)
  - format=date-time data поля отдают datetime ISO, format=date — по-прежнему YYYY-MM-DD

Также добавил скаффолд для D-pack (history drawer + schema diff util) — не
интегрировано в роуты, доделаем в следующем коммите.
This commit is contained in:
Zimin A.N.
2026-05-04 03:29:17 +03:00
parent 987559a6ab
commit 0289cf5ff2
4 changed files with 302 additions and 22 deletions
+180 -19
View File
@@ -16,6 +16,7 @@ export type PropertyDef = {
name: string
kind: PropertyKind
required: boolean
unique: boolean
description?: string
enumValues?: string[]
minLength?: number
@@ -38,16 +39,23 @@ export const PROPERTY_KINDS: PropertyKind[] = [
'array_string',
]
export const newPropertyDef = (): PropertyDef => ({
id: typeof crypto !== 'undefined' && 'randomUUID' in crypto
const newId = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
export const newPropertyDef = (): PropertyDef => ({
id: newId(),
name: '',
kind: 'string',
required: false,
unique: false,
})
export const buildSchemaJson = (props: PropertyDef[]): JsonSchema => {
export const buildSchemaJson = (
props: PropertyDef[],
idSource?: string,
): JsonSchema => {
const properties: Record<string, JsonSchema> = {}
const required: string[] = []
for (const p of props) {
@@ -55,21 +63,24 @@ export const buildSchemaJson = (props: PropertyDef[]): JsonSchema => {
properties[p.name] = propertyToSchema(p)
if (p.required) required.push(p.name)
}
return {
const schema: Record<string, unknown> = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
additionalProperties: false,
required: required.length > 0 ? required : undefined,
properties,
} as JsonSchema
}
if (idSource) schema['x-id-source'] = idSource
return schema as JsonSchema
}
const propertyToSchema = (p: PropertyDef): JsonSchema => {
const desc = p.description?.trim() || undefined
const uniqueExt = p.unique ? { 'x-unique': true } : {}
switch (p.kind) {
case 'string': {
const s: JsonSchema = { type: 'string', description: desc }
const s: JsonSchema = { type: 'string', description: desc, ...uniqueExt }
if (p.minLength != null) s.minLength = p.minLength
if (p.maxLength != null) s.maxLength = p.maxLength
if (p.pattern) s.pattern = p.pattern
@@ -77,25 +88,26 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
}
case 'integer':
case 'number': {
const s: JsonSchema = { type: p.kind, description: desc }
const s: JsonSchema = { type: p.kind, description: desc, ...uniqueExt }
if (p.minimum != null) s.minimum = p.minimum
if (p.maximum != null) s.maximum = p.maximum
return s
}
case 'boolean':
return { type: 'boolean', description: desc }
return { type: 'boolean', description: desc, ...uniqueExt }
case 'enum':
return {
type: 'string',
description: desc,
enum: p.enumValues ?? [],
...uniqueExt,
}
case 'localized':
return {
type: 'object',
description: desc,
['x-localized']: true,
// patternProperties not modeled in our JsonSchema TS yet — passes through as raw
...uniqueExt,
...({
patternProperties: {
'^[a-z]{2}-[A-Z]{2}$': { type: 'string' },
@@ -103,9 +115,9 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
} as object),
}
case 'date':
return { type: 'string', format: 'date', description: desc }
return { type: 'string', format: 'date', description: desc, ...uniqueExt }
case 'datetime':
return { type: 'string', format: 'date-time', description: desc }
return { type: 'string', format: 'date-time', description: desc, ...uniqueExt }
case 'array_string':
return {
type: 'array',
@@ -114,19 +126,25 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
? ({ type: 'string', enum: p.enumValues } as JsonSchema)
: { type: 'string' },
...(p.uniqueItems ? { uniqueItems: true } : {}),
...uniqueExt,
} as JsonSchema
}
}
export const parseSchemaJson = (schema: JsonSchema | undefined): PropertyDef[] => {
if (!schema || !schema.properties) return []
export const parseSchemaJson = (
schema: JsonSchema | undefined,
): { properties: PropertyDef[]; idSource: string | undefined } => {
if (!schema || !schema.properties) {
return { properties: [], idSource: (schema as { 'x-id-source'?: string } | undefined)?.['x-id-source'] }
}
const required = new Set(schema.required ?? [])
const list: PropertyDef[] = []
for (const [name, raw] of Object.entries(schema.properties)) {
const def = inferKind(name, raw, required.has(name))
if (def) list.push(def)
}
return list
const idSource = (schema as { 'x-id-source'?: string })['x-id-source']
return { properties: list, idSource }
}
const inferKind = (
@@ -134,12 +152,12 @@ const inferKind = (
raw: JsonSchema,
isRequired: boolean,
): PropertyDef | null => {
const base: Pick<PropertyDef, 'id' | 'name' | 'required' | 'description'> = {
id: typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
const unique = Boolean((raw as { 'x-unique'?: boolean })['x-unique'])
const base = {
id: newId(),
name,
required: isRequired,
unique,
description: raw.description,
}
@@ -177,3 +195,146 @@ const inferKind = (
}
return { ...base, kind: 'string' }
}
// === schema diff (breaking detection) ===
export type SchemaChange = {
severity: 'breaking' | 'compatible' | 'metadata'
field: string
reason: string
}
const kindOf = (s: JsonSchema): string => {
if (s['x-localized']) return 'localized'
if (s.enum) return 'enum'
if (s.type === 'string' && s.format === 'date') return 'date'
if (s.type === 'string' && s.format === 'date-time') return 'datetime'
return s.type ?? 'unknown'
}
export const diffSchemas = (
prev: JsonSchema | undefined,
next: JsonSchema,
): SchemaChange[] => {
const changes: SchemaChange[] = []
if (!prev) return changes // create — нет с чем сравнивать
const prevProps = prev.properties ?? {}
const nextProps = next.properties ?? {}
const prevReq = new Set(prev.required ?? [])
const nextReq = new Set(next.required ?? [])
for (const name of Object.keys(prevProps)) {
if (!(name in nextProps)) {
changes.push({ severity: 'breaking', field: name, reason: 'removed' })
continue
}
const a = prevProps[name]
const b = nextProps[name]
const ka = kindOf(a)
const kb = kindOf(b)
if (ka !== kb) {
changes.push({ severity: 'breaking', field: name, reason: `type ${ka}${kb}` })
continue
}
const wasReq = prevReq.has(name)
const isReq = nextReq.has(name)
if (!wasReq && isReq) {
changes.push({ severity: 'breaking', field: name, reason: 'now required' })
} else if (wasReq && !isReq) {
changes.push({ severity: 'compatible', field: name, reason: 'now optional' })
}
const aEnum = (a.enum ?? []) as unknown[]
const bEnum = (b.enum ?? []) as unknown[]
if (aEnum.length > 0 || bEnum.length > 0) {
const removed = aEnum.filter((v) => !bEnum.includes(v))
const added = bEnum.filter((v) => !aEnum.includes(v))
if (removed.length > 0) {
changes.push({
severity: 'breaking',
field: name,
reason: `enum value removed: ${removed.join(', ')}`,
})
}
if (added.length > 0) {
changes.push({
severity: 'compatible',
field: name,
reason: `enum value added: ${added.join(', ')}`,
})
}
}
if (a.maxLength != null && b.maxLength != null && b.maxLength < a.maxLength) {
changes.push({
severity: 'breaking',
field: name,
reason: `maxLength tightened ${a.maxLength}${b.maxLength}`,
})
}
if (a.minimum != null && b.minimum != null && b.minimum > a.minimum) {
changes.push({
severity: 'breaking',
field: name,
reason: `minimum tightened ${a.minimum}${b.minimum}`,
})
}
if (a.maximum != null && b.maximum != null && b.maximum < a.maximum) {
changes.push({
severity: 'breaking',
field: name,
reason: `maximum tightened ${a.maximum}${b.maximum}`,
})
}
if (a.pattern && b.pattern && a.pattern !== b.pattern) {
changes.push({
severity: 'breaking',
field: name,
reason: `pattern changed (${a.pattern}${b.pattern})`,
})
}
if ((a.description ?? '') !== (b.description ?? '')) {
changes.push({ severity: 'metadata', field: name, reason: 'description changed' })
}
}
for (const name of Object.keys(nextProps)) {
if (!(name in prevProps)) {
const isReq = nextReq.has(name)
changes.push({
severity: isReq ? 'breaking' : 'compatible',
field: name,
reason: isReq ? 'new required field' : 'new optional field',
})
}
}
return changes
}
export const hasBreakingChanges = (changes: SchemaChange[]): boolean =>
changes.some((c) => c.severity === 'breaking')
export const suggestVersionBump = (
current: string | undefined,
changes: SchemaChange[],
): string => {
const v = parseVersion(current ?? '1.0.0')
if (changes.length === 0) return formatVersion(v)
if (hasBreakingChanges(changes)) return formatVersion({ ...v, major: v.major + 1, minor: 0, patch: 0 })
const onlyMetadata = changes.every((c) => c.severity === 'metadata')
if (onlyMetadata) return formatVersion({ ...v, patch: v.patch + 1 })
return formatVersion({ ...v, minor: v.minor + 1, patch: 0 })
}
const parseVersion = (s: string): { major: number; minor: number; patch: number } => {
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(s)
if (!m) return { major: 1, minor: 0, patch: 0 }
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
}
const formatVersion = (v: { major: number; minor: number; patch: number }): string =>
`${v.major}.${v.minor}.${v.patch}`