@mvriu5/payload-ai 1.1.1 → 1.2.0

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.
@@ -0,0 +1,575 @@
1
+ import { getSafeProposalLabel, getOptionValue, isRecord } from "./shared.js";
2
+ import { createLexicalText, getSchemaFields, normalizeAuthData, normalizeDataForFields } from "./normalizeData.js";
3
+ const isTitleLikeField = (field, titleFieldName)=>{
4
+ const normalizedName = field.name?.toLowerCase();
5
+ return Boolean(field.name && (field.name === titleFieldName || normalizedName === "title" || normalizedName === "name" || normalizedName === "label" || normalizedName === "headline"));
6
+ };
7
+ const getFieldOptionValues = (field)=>{
8
+ return (field.options || []).map((option)=>getOptionValue(option)).filter((option)=>Boolean(option));
9
+ };
10
+ const escapeRegExp = (value)=>value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
+ const getFieldNameVariants = (fieldName)=>{
12
+ if (!fieldName) return [];
13
+ const spacedName = fieldName.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").toLowerCase();
14
+ return Array.from(new Set([
15
+ fieldName.toLowerCase(),
16
+ spacedName
17
+ ])).filter(Boolean);
18
+ };
19
+ const inferOptionValueFromText = ({ field, requireFieldMention = false, text })=>{
20
+ const normalizedText = text?.trim().toLowerCase();
21
+ if (!normalizedText) return undefined;
22
+ if (requireFieldMention) {
23
+ const fieldMentioned = getFieldNameVariants(field.name).some((variant)=>{
24
+ const pattern = new RegExp(`\\b${escapeRegExp(variant)}\\b`, "i");
25
+ return pattern.test(normalizedText);
26
+ });
27
+ if (!fieldMentioned) return undefined;
28
+ }
29
+ const optionValues = getFieldOptionValues(field).sort((left, right)=>right.length - left.length);
30
+ return optionValues.find((optionValue)=>{
31
+ const pattern = new RegExp(`\\b${escapeRegExp(optionValue.toLowerCase())}\\b`, "i");
32
+ return pattern.test(normalizedText);
33
+ });
34
+ };
35
+ const getDefaultFieldValue = ({ field, inferenceText, label, titleFieldName })=>{
36
+ if (field.name === "_status") return "draft";
37
+ if (field.type === "checkbox") return false;
38
+ if (field.type === "select" || field.type === "radio") {
39
+ const inferredOptionValue = inferOptionValueFromText({
40
+ field,
41
+ requireFieldMention: true,
42
+ text: inferenceText || label
43
+ });
44
+ if (inferredOptionValue !== undefined) {
45
+ return inferredOptionValue;
46
+ }
47
+ if (field.defaultValue !== undefined) {
48
+ return field.defaultValue;
49
+ }
50
+ return getOptionValue(field.options?.[0]);
51
+ }
52
+ if (field.defaultValue !== undefined) return field.defaultValue;
53
+ if ([
54
+ "email",
55
+ "text",
56
+ "textarea"
57
+ ].includes(field.type || "") && isTitleLikeField(field, titleFieldName)) {
58
+ return getSafeProposalLabel(label);
59
+ }
60
+ return undefined;
61
+ };
62
+ const addIssue = (issues, issue)=>{
63
+ issues.push(issue);
64
+ };
65
+ const normalizeRelationshipScalar = (value)=>{
66
+ if (typeof value === "number") {
67
+ if (!Number.isInteger(value) || value <= 0) return undefined;
68
+ return value;
69
+ }
70
+ const trimmedValue = value.trim();
71
+ if (!trimmedValue || /\s/.test(trimmedValue)) return undefined;
72
+ if (/^\d+$/.test(trimmedValue) && Number(trimmedValue) <= 0) return undefined;
73
+ return trimmedValue;
74
+ };
75
+ const normalizeRelationshipValue = ({ field, issues, path, value })=>{
76
+ const allowedRelationTargets = Array.isArray(field.relationTo) ? field.relationTo.filter((item)=>typeof item === "string") : typeof field.relationTo === "string" ? [
77
+ field.relationTo
78
+ ] : [];
79
+ const normalizeSingle = (item, itemPath)=>{
80
+ if (typeof item === "number") {
81
+ const normalizedValue = normalizeRelationshipScalar(item);
82
+ if (normalizedValue !== undefined) {
83
+ return normalizedValue;
84
+ }
85
+ addIssue(issues, {
86
+ code: "invalid_relationship",
87
+ message: "Relationship and upload fields must use a valid document ID.",
88
+ path: itemPath
89
+ });
90
+ return undefined;
91
+ }
92
+ if (typeof item === "string") {
93
+ const normalizedValue = normalizeRelationshipScalar(item);
94
+ if (normalizedValue !== undefined) {
95
+ return normalizedValue;
96
+ }
97
+ addIssue(issues, {
98
+ code: "invalid_relationship",
99
+ message: "Relationship and upload fields must use a valid document ID, not free text.",
100
+ path: itemPath
101
+ });
102
+ return undefined;
103
+ }
104
+ if (!isRecord(item)) {
105
+ addIssue(issues, {
106
+ code: "invalid_relationship",
107
+ message: "Relationship and upload fields must use a document ID or an object with id/value.",
108
+ path: itemPath
109
+ });
110
+ return undefined;
111
+ }
112
+ if (typeof item.id === "string" || typeof item.id === "number") {
113
+ const normalizedValue = normalizeRelationshipScalar(item.id);
114
+ if (normalizedValue !== undefined) {
115
+ return normalizedValue;
116
+ }
117
+ addIssue(issues, {
118
+ code: "invalid_relationship",
119
+ message: "Relationship and upload fields must use a valid document ID.",
120
+ path: itemPath
121
+ });
122
+ return undefined;
123
+ }
124
+ if (typeof item.value === "string" || typeof item.value === "number") {
125
+ const normalizedValue = normalizeRelationshipScalar(item.value);
126
+ if (normalizedValue === undefined) {
127
+ addIssue(issues, {
128
+ code: "invalid_relationship",
129
+ message: "Relationship and upload fields must use a valid document ID.",
130
+ path: itemPath
131
+ });
132
+ return undefined;
133
+ }
134
+ if (allowedRelationTargets.length > 1) {
135
+ const relationTo = typeof item.relationTo === "string" ? item.relationTo : null;
136
+ if (!relationTo || !allowedRelationTargets.includes(relationTo)) {
137
+ addIssue(issues, {
138
+ code: "invalid_relationship",
139
+ message: `Relationship must include one of: ${allowedRelationTargets.join(", ")}.`,
140
+ path: itemPath
141
+ });
142
+ return undefined;
143
+ }
144
+ return {
145
+ relationTo,
146
+ value: normalizedValue
147
+ };
148
+ }
149
+ return normalizedValue;
150
+ }
151
+ addIssue(issues, {
152
+ code: "invalid_relationship",
153
+ message: "Relationship and upload fields must use a document ID or an object with id/value.",
154
+ path: itemPath
155
+ });
156
+ return undefined;
157
+ };
158
+ if (field.hasMany) {
159
+ if (!Array.isArray(value)) {
160
+ addIssue(issues, {
161
+ code: "invalid_relationship",
162
+ message: "Relationship field expects an array of document IDs.",
163
+ path
164
+ });
165
+ return undefined;
166
+ }
167
+ const normalizedValues = value.map((item, index)=>normalizeSingle(item, `${path}.${index}`)).filter((item)=>item !== undefined);
168
+ return normalizedValues;
169
+ }
170
+ return normalizeSingle(value, path);
171
+ };
172
+ const normalizeFieldValue = ({ coercedFields, enforceRequiredChildren, field, inferenceText, issues, label, mode, path, titleFieldName, value })=>{
173
+ if (field.type === "group") {
174
+ if (!isRecord(value)) {
175
+ addIssue(issues, {
176
+ code: "invalid_container",
177
+ message: "Group fields must be objects.",
178
+ path
179
+ });
180
+ return undefined;
181
+ }
182
+ return normalizeRecordForFields({
183
+ allowSafeFallback: false,
184
+ coercedFields,
185
+ enforceRequiredChildren: mode === "create" || enforceRequiredChildren,
186
+ fields: field.fields || [],
187
+ inferenceText,
188
+ issues,
189
+ label,
190
+ mode,
191
+ path,
192
+ titleFieldName,
193
+ value
194
+ });
195
+ }
196
+ if (field.type === "array") {
197
+ if (!Array.isArray(value)) {
198
+ addIssue(issues, {
199
+ code: "invalid_array",
200
+ message: "Array fields must use an array value.",
201
+ path
202
+ });
203
+ return undefined;
204
+ }
205
+ const childFields = (field.fields || []).filter((childField)=>Boolean(childField.name));
206
+ const itemLabelField = childFields.find((childField)=>childField.name === "label") || childFields.find((childField)=>childField.name === "title") || childFields.find((childField)=>childField.name === "name") || childFields.find((childField)=>childField.name === "value") || childFields[0];
207
+ return value.map((item, index)=>{
208
+ const itemPath = `${path}.${index}`;
209
+ if (!isRecord(item)) {
210
+ if (!itemLabelField) {
211
+ addIssue(issues, {
212
+ code: "invalid_array",
213
+ message: "Array items must be objects for this field.",
214
+ path: itemPath
215
+ });
216
+ return undefined;
217
+ }
218
+ const wrappedItem = {
219
+ [itemLabelField.name]: item
220
+ };
221
+ return normalizeRecordForFields({
222
+ allowSafeFallback: false,
223
+ coercedFields,
224
+ enforceRequiredChildren: true,
225
+ fields: field.fields || [],
226
+ inferenceText,
227
+ issues,
228
+ label,
229
+ mode,
230
+ path: itemPath,
231
+ titleFieldName,
232
+ value: wrappedItem
233
+ });
234
+ }
235
+ return normalizeRecordForFields({
236
+ allowSafeFallback: false,
237
+ coercedFields,
238
+ enforceRequiredChildren: true,
239
+ fields: field.fields || [],
240
+ inferenceText,
241
+ issues,
242
+ label,
243
+ mode,
244
+ path: itemPath,
245
+ titleFieldName,
246
+ value: item
247
+ });
248
+ });
249
+ }
250
+ if (field.type === "blocks") {
251
+ if (!Array.isArray(value)) {
252
+ addIssue(issues, {
253
+ code: "invalid_blocks",
254
+ message: "Blocks fields must use an array value.",
255
+ path
256
+ });
257
+ return undefined;
258
+ }
259
+ return value.map((item, index)=>{
260
+ const itemPath = `${path}.${index}`;
261
+ if (!isRecord(item)) {
262
+ addIssue(issues, {
263
+ code: "invalid_blocks",
264
+ message: "Each block entry must be an object.",
265
+ path: itemPath
266
+ });
267
+ return undefined;
268
+ }
269
+ const blockType = typeof item.blockType === "string" ? item.blockType : typeof item.type === "string" ? item.type : typeof item.slug === "string" ? item.slug : null;
270
+ if (!blockType) {
271
+ addIssue(issues, {
272
+ code: "invalid_block_type",
273
+ message: "Each block entry must include blockType.",
274
+ path: itemPath
275
+ });
276
+ return undefined;
277
+ }
278
+ const block = field.blocks?.find((candidate)=>candidate.slug === blockType);
279
+ if (!block) {
280
+ addIssue(issues, {
281
+ code: "invalid_block_type",
282
+ message: `Unknown block type "${blockType}".`,
283
+ path: `${itemPath}.blockType`
284
+ });
285
+ return undefined;
286
+ }
287
+ const { blockType: _blockType, slug: _slug, type: _type, ...blockData } = item;
288
+ const normalizedBlock = normalizeRecordForFields({
289
+ allowSafeFallback: false,
290
+ coercedFields,
291
+ enforceRequiredChildren: true,
292
+ fields: block.fields || [],
293
+ inferenceText,
294
+ issues,
295
+ label,
296
+ mode,
297
+ path: itemPath,
298
+ titleFieldName,
299
+ value: blockData
300
+ });
301
+ return {
302
+ ...normalizedBlock,
303
+ blockType
304
+ };
305
+ });
306
+ }
307
+ if (field.type === "checkbox") {
308
+ if (typeof value === "boolean") return value;
309
+ if (value === "true") return true;
310
+ if (value === "false") return false;
311
+ addIssue(issues, {
312
+ code: "invalid_checkbox",
313
+ message: "Checkbox fields must use true or false.",
314
+ path
315
+ });
316
+ return undefined;
317
+ }
318
+ if (field.type === "relationship" || field.type === "upload") {
319
+ return normalizeRelationshipValue({
320
+ field,
321
+ issues,
322
+ path,
323
+ value
324
+ });
325
+ }
326
+ if (field.type === "richText") return createLexicalText(value);
327
+ if (field.type === "select" || field.type === "radio") {
328
+ const optionValues = getFieldOptionValues(field);
329
+ const stringValue = value === null ? "" : String(value);
330
+ const inferredOptionValue = inferOptionValueFromText({
331
+ field,
332
+ requireFieldMention: true,
333
+ text: inferenceText || label
334
+ });
335
+ if (inferredOptionValue && optionValues.includes(inferredOptionValue)) {
336
+ if (stringValue !== inferredOptionValue) {
337
+ coercedFields.push(path);
338
+ }
339
+ return inferredOptionValue;
340
+ }
341
+ if (stringValue && optionValues.includes(stringValue)) return stringValue;
342
+ addIssue(issues, {
343
+ code: "invalid_option",
344
+ message: `Field must use one of: ${optionValues.join(", ")}.`,
345
+ path
346
+ });
347
+ return undefined;
348
+ }
349
+ if ([
350
+ "email",
351
+ "text",
352
+ "textarea"
353
+ ].includes(field.type || "")) {
354
+ return value === null ? value : String(value);
355
+ }
356
+ if (field.type === "date") {
357
+ const date = new Date(String(value));
358
+ if (Number.isNaN(date.getTime())) {
359
+ addIssue(issues, {
360
+ code: "invalid_date",
361
+ message: "Date fields must use a valid date value.",
362
+ path
363
+ });
364
+ return undefined;
365
+ }
366
+ return date.toISOString();
367
+ }
368
+ return value;
369
+ };
370
+ const normalizeRecordForFields = ({ allowSafeFallback, coercedFields, enforceRequiredChildren, fields, inferenceText, issues, label, mode, path, titleFieldName, value })=>{
371
+ const normalizedData = {};
372
+ const fieldsByName = new Map(fields.filter((field)=>Boolean(field.name)).map((field)=>[
373
+ field.name,
374
+ field
375
+ ]));
376
+ for (const [key, fieldValue] of Object.entries(value)){
377
+ const field = fieldsByName.get(key);
378
+ const fieldPath = path ? `${path}.${key}` : key;
379
+ if (!field) {
380
+ addIssue(issues, {
381
+ code: "unknown_field",
382
+ message: "Field does not exist in the schema.",
383
+ path: fieldPath
384
+ });
385
+ continue;
386
+ }
387
+ const normalizedValue = normalizeFieldValue({
388
+ coercedFields,
389
+ enforceRequiredChildren,
390
+ field,
391
+ inferenceText,
392
+ issues,
393
+ label,
394
+ mode,
395
+ path: fieldPath,
396
+ titleFieldName,
397
+ value: fieldValue
398
+ });
399
+ if (normalizedValue !== undefined) {
400
+ normalizedData[key] = normalizedValue;
401
+ }
402
+ }
403
+ if (mode === "create" || enforceRequiredChildren) {
404
+ for (const field of fields.filter((candidate)=>Boolean(candidate.name) && candidate.required === true)){
405
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
406
+ if (normalizedData[field.name] === undefined) {
407
+ if (allowSafeFallback) {
408
+ const fallbackValue = getDefaultFieldValue({
409
+ field,
410
+ inferenceText,
411
+ label,
412
+ titleFieldName
413
+ });
414
+ if (fallbackValue !== undefined) {
415
+ normalizedData[field.name] = fallbackValue;
416
+ continue;
417
+ }
418
+ }
419
+ addIssue(issues, {
420
+ code: "missing_required_field",
421
+ message: "Required field is missing.",
422
+ path: fieldPath
423
+ });
424
+ }
425
+ }
426
+ }
427
+ for (const field of fields.filter((candidate)=>Boolean(candidate.name))){
428
+ if (normalizedData[field.name] !== undefined) continue;
429
+ if (field.type !== "select" && field.type !== "radio") continue;
430
+ const inferredOptionValue = inferOptionValueFromText({
431
+ field,
432
+ requireFieldMention: true,
433
+ text: inferenceText || label
434
+ });
435
+ if (inferredOptionValue !== undefined) {
436
+ normalizedData[field.name] = inferredOptionValue;
437
+ coercedFields.push(path ? `${path}.${field.name}` : field.name);
438
+ }
439
+ }
440
+ return normalizedData;
441
+ };
442
+ const prepareSingleLocaleData = ({ coercedFields, data, fields, inferenceText, issues, label, mode, titleFieldName })=>{
443
+ const normalizedData = normalizeRecordForFields({
444
+ allowSafeFallback: true,
445
+ coercedFields,
446
+ enforceRequiredChildren: false,
447
+ fields,
448
+ inferenceText,
449
+ issues,
450
+ label,
451
+ mode,
452
+ path: "",
453
+ titleFieldName,
454
+ value: data
455
+ });
456
+ return normalizeDataForFields(fields, normalizedData).data;
457
+ };
458
+ export const prepareProposalWriteData = ({ collectionConfig, data, inferenceText, label, localizedData, mode })=>{
459
+ const schemaFields = getSchemaFields(collectionConfig);
460
+ const titleFieldName = typeof collectionConfig?.admin?.useAsTitle === "string" ? collectionConfig.admin.useAsTitle : undefined;
461
+ const issues = [];
462
+ const coercedFields = [];
463
+ if (localizedData) {
464
+ const localeEntries = Object.entries(localizedData);
465
+ if (localeEntries.length === 0) {
466
+ addIssue(issues, {
467
+ code: "empty_localized_data",
468
+ message: "localizedData must contain at least one locale entry.",
469
+ path: "localizedData"
470
+ });
471
+ return {
472
+ coercedFields,
473
+ issues,
474
+ localizedData: {}
475
+ };
476
+ }
477
+ const normalizedLocalizedData = {};
478
+ for (const [locale, localeValue] of localeEntries){
479
+ if (!isRecord(localeValue)) {
480
+ addIssue(issues, {
481
+ code: "invalid_container",
482
+ message: "Each locale entry must be an object.",
483
+ path: `localizedData.${locale}`
484
+ });
485
+ continue;
486
+ }
487
+ const normalizedLocaleData = prepareSingleLocaleData({
488
+ coercedFields,
489
+ data: localeValue,
490
+ fields: schemaFields,
491
+ inferenceText,
492
+ issues,
493
+ label,
494
+ mode,
495
+ titleFieldName
496
+ });
497
+ normalizedLocalizedData[locale] = normalizedLocaleData;
498
+ }
499
+ const [firstLocale] = localeEntries[0] || [];
500
+ if (firstLocale) {
501
+ for (const field of schemaFields.filter((candidate)=>Boolean(candidate.name))){
502
+ if (!field.localized) {
503
+ for (const [locale, localeValue] of Object.entries(normalizedLocalizedData)){
504
+ if (locale === firstLocale) continue;
505
+ if (localeValue[field.name] !== undefined) {
506
+ addIssue(issues, {
507
+ code: "non_localized_field_in_secondary_locale",
508
+ message: "Non-localized fields may only be set in the first locale entry.",
509
+ path: `localizedData.${locale}.${field.name}`
510
+ });
511
+ }
512
+ }
513
+ }
514
+ }
515
+ }
516
+ const normalizedResult = {};
517
+ for (const [locale, localeValue] of Object.entries(normalizedLocalizedData)){
518
+ normalizedResult[locale] = normalizeAuthData(collectionConfig, {
519
+ coercedFields: [],
520
+ data: localeValue,
521
+ droppedFields: []
522
+ }).data;
523
+ }
524
+ return {
525
+ coercedFields,
526
+ issues,
527
+ localizedData: normalizedResult
528
+ };
529
+ }
530
+ const sourceData = data || {};
531
+ const normalizedData = prepareSingleLocaleData({
532
+ coercedFields,
533
+ data: sourceData,
534
+ fields: schemaFields,
535
+ inferenceText,
536
+ issues,
537
+ label,
538
+ mode,
539
+ titleFieldName
540
+ });
541
+ const authNormalized = normalizeAuthData(collectionConfig, {
542
+ coercedFields: [],
543
+ data: normalizedData,
544
+ droppedFields: []
545
+ });
546
+ coercedFields.push(...authNormalized.coercedFields);
547
+ return {
548
+ coercedFields,
549
+ data: authNormalized.data,
550
+ issues
551
+ };
552
+ };
553
+ export const applyLocalizedRequiredFallbackToPreparedData = ({ fallbackSource, fields, preparedData })=>{
554
+ const mergedData = {
555
+ ...preparedData
556
+ };
557
+ for (const field of fields.filter((candidate)=>Boolean(candidate.name))){
558
+ if (field.localized && field.required && mergedData[field.name] === undefined && fallbackSource[field.name] !== undefined) {
559
+ mergedData[field.name] = fallbackSource[field.name];
560
+ continue;
561
+ }
562
+ if (field.type === "group" && field.fields?.length && isRecord(fallbackSource[field.name])) {
563
+ const currentValue = isRecord(mergedData[field.name]) ? mergedData[field.name] : {};
564
+ const nestedValue = applyLocalizedRequiredFallbackToPreparedData({
565
+ fallbackSource: fallbackSource[field.name],
566
+ fields: field.fields,
567
+ preparedData: currentValue
568
+ });
569
+ if (Object.keys(nestedValue).length > 0) {
570
+ mergedData[field.name] = nestedValue;
571
+ }
572
+ }
573
+ }
574
+ return mergedData;
575
+ };
@@ -1,5 +1,6 @@
1
1
  import type { PayloadHandler } from "payload";
2
2
  import { type ResolvedCollectionPermissionMap } from "./collectionPermissions.js";
3
+ import { type CollectionConfig as NormalizeCollectionConfig } from "./normalizeData.js";
3
4
  export type ChatMention = {
4
5
  collection?: string;
5
6
  id?: string;
@@ -9,9 +10,7 @@ export type ChatMention = {
9
10
  type?: "block" | "collection" | "doc" | "global" | "locale";
10
11
  };
11
12
  export type FieldConfig = {
12
- admin?: {
13
- condition?: unknown;
14
- };
13
+ admin?: Record<string, unknown>;
15
14
  blocks?: BlockConfig[];
16
15
  defaultValue?: unknown;
17
16
  fields?: FieldConfig[];
@@ -35,9 +34,6 @@ type BlockConfig = {
35
34
  };
36
35
  slug: string;
37
36
  };
38
- type VersionConfig = boolean | {
39
- drafts?: boolean | Record<string, unknown>;
40
- };
41
37
  type CollectionLikeConfig = {
42
38
  admin?: {
43
39
  useAsTitle?: string;
@@ -51,7 +47,7 @@ type CollectionLikeConfig = {
51
47
  singular?: unknown;
52
48
  };
53
49
  slug: string;
54
- versions?: VersionConfig;
50
+ versions?: NormalizeCollectionConfig["versions"];
55
51
  };
56
52
  type MentionContext = {
57
53
  blockContexts: (Record<string, unknown> & {