@simmalugnt-se/payload-editor-assistant 0.12.1 → 0.13.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +37 -0
  3. package/dist/chat-images.d.ts +20 -0
  4. package/dist/chat-images.js +61 -0
  5. package/dist/components/ChatImages.d.ts +20 -0
  6. package/dist/components/ChatImages.js +113 -0
  7. package/dist/components/EditorAssistantAside.d.ts +1 -1
  8. package/dist/components/EditorAssistantAside.js +31 -17
  9. package/dist/components/EditorAssistantDocumentBridge.js +1 -1
  10. package/dist/components/ProposalReview.d.ts +1 -1
  11. package/dist/components/ProposalReview.js +7 -3
  12. package/dist/components/assistant-context.d.ts +2 -0
  13. package/dist/components/batch-link.js +2 -1
  14. package/dist/components/chat-image-file.d.ts +3 -0
  15. package/dist/components/chat-image-file.js +48 -0
  16. package/dist/components/conversation-store.js +34 -4
  17. package/dist/components/copy.d.ts +14 -0
  18. package/dist/components/copy.js +14 -0
  19. package/dist/domain/batch.js +10 -3
  20. package/dist/domain/decide.js +9 -0
  21. package/dist/domain/new-block-review.d.ts +7 -0
  22. package/dist/domain/new-block-review.js +97 -0
  23. package/dist/domain/propose.d.ts +2 -0
  24. package/dist/domain/propose.js +39 -7
  25. package/dist/endpoints/chat.d.ts +3 -0
  26. package/dist/endpoints/chat.js +31 -2
  27. package/dist/endpoints/decide.js +27 -6
  28. package/dist/form/batch-revision.d.ts +6 -0
  29. package/dist/form/batch-revision.js +39 -0
  30. package/dist/form/diff.js +3 -2
  31. package/dist/form/relation-shape.d.ts +10 -5
  32. package/dist/form/relation-shape.js +104 -28
  33. package/dist/runtime/agent.d.ts +3 -1
  34. package/dist/runtime/agent.js +100 -8
  35. package/dist/runtime/context.d.ts +10 -8
  36. package/dist/runtime/context.js +16 -10
  37. package/dist/runtime/debug.d.ts +10 -0
  38. package/dist/runtime/debug.js +132 -0
  39. package/dist/runtime/system-prompt.js +4 -2
  40. package/dist/schema/fields.d.ts +6 -0
  41. package/dist/schema/fields.js +20 -2
  42. package/dist/styles/assistant.css +51 -0
  43. package/package.json +1 -1
@@ -12,6 +12,13 @@ export declare const COPY: {
12
12
  readonly collapseLabel: "Collapse assistant";
13
13
  readonly placeholder: "Message the assistant";
14
14
  readonly send: "Send";
15
+ readonly attachImage: "Add images";
16
+ readonly removeImage: "Remove image";
17
+ readonly imageOmitted: "Older image omitted";
18
+ readonly imagePreparing: "Preparing images…";
19
+ readonly imageLimit: "Attach up to three images per message.";
20
+ readonly imageTooLarge: "These images are too large. Try fewer or smaller images.";
21
+ readonly imageInvalid: "Choose a PNG, JPEG, WebP or GIF image up to 10 MB.";
15
22
  readonly stop: "Stop";
16
23
  readonly working: "Working…";
17
24
  readonly steps: {
@@ -75,6 +82,13 @@ export declare const COPY: {
75
82
  readonly collapseLabel: "Fäll ihop assistenten";
76
83
  readonly placeholder: "Skriv till assistenten";
77
84
  readonly send: "Skicka";
85
+ readonly attachImage: "Lägg till bilder";
86
+ readonly removeImage: "Ta bort bild";
87
+ readonly imageOmitted: "Äldre bild utelämnad";
88
+ readonly imagePreparing: "Förbereder bilder…";
89
+ readonly imageLimit: "Bifoga högst tre bilder per meddelande.";
90
+ readonly imageTooLarge: "Bilderna är för stora. Prova färre eller mindre bilder.";
91
+ readonly imageInvalid: "Välj en PNG-, JPEG-, WebP- eller GIF-bild på högst 10 MB.";
78
92
  readonly stop: "Stoppa";
79
93
  readonly working: "Arbetar…";
80
94
  readonly steps: {
@@ -13,6 +13,13 @@ export const COPY = {
13
13
  collapseLabel: "Collapse assistant",
14
14
  placeholder: "Message the assistant",
15
15
  send: "Send",
16
+ attachImage: "Add images",
17
+ removeImage: "Remove image",
18
+ imageOmitted: "Older image omitted",
19
+ imagePreparing: "Preparing images…",
20
+ imageLimit: "Attach up to three images per message.",
21
+ imageTooLarge: "These images are too large. Try fewer or smaller images.",
22
+ imageInvalid: "Choose a PNG, JPEG, WebP or GIF image up to 10 MB.",
16
23
  stop: "Stop",
17
24
  working: "Working…",
18
25
  steps: {
@@ -76,6 +83,13 @@ export const COPY = {
76
83
  collapseLabel: "Fäll ihop assistenten",
77
84
  placeholder: "Skriv till assistenten",
78
85
  send: "Skicka",
86
+ attachImage: "Lägg till bilder",
87
+ removeImage: "Ta bort bild",
88
+ imageOmitted: "Äldre bild utelämnad",
89
+ imagePreparing: "Förbereder bilder…",
90
+ imageLimit: "Bifoga högst tre bilder per meddelande.",
91
+ imageTooLarge: "Bilderna är för stora. Prova färre eller mindre bilder.",
92
+ imageInvalid: "Välj en PNG-, JPEG-, WebP- eller GIF-bild på högst 10 MB.",
79
93
  stop: "Stoppa",
80
94
  working: "Arbetar…",
81
95
  steps: {
@@ -1,3 +1,4 @@
1
+ import { hashBatchRevision } from "../form/batch-revision.js";
1
2
  import { changedPaths } from "../form/changed-paths.js";
2
3
  import { compileOperations } from "../form/compile.js";
3
4
  import { hashFormRevision } from "../form/hash.js";
@@ -188,10 +189,13 @@ export async function applyBatchTarget(req, options, input) {
188
189
  if (!saved) {
189
190
  return fail("tool_denied", "Document is not accessible.");
190
191
  }
191
- if ((await hashFormRevision(saved.form)) !== record.formRevision) {
192
+ const fields = entityFields(req, options, { collection: record.targetCollection });
193
+ const revision = record.formRevision?.startsWith("batch-v1:")
194
+ ? await hashBatchRevision(saved.form, fields)
195
+ : await hashFormRevision(saved.form);
196
+ if (revision !== record.formRevision) {
192
197
  return fail("stale_context", "The document changed after the proposal.");
193
198
  }
194
- const fields = entityFields(req, options, { collection: record.targetCollection });
195
199
  const edited = withEdits(stored, input.edits, fields, saved.form);
196
200
  if (!edited.ok) {
197
201
  // Not the editor's approval to spend: the card can still be saved without the edit.
@@ -210,7 +214,10 @@ export async function applyBatchTarget(req, options, input) {
210
214
  return fail("tool_denied", finalized.error);
211
215
  }
212
216
  const paths = changedPaths(operations);
213
- const relationError = relationValueError(fields, finalized.data, paths);
217
+ const relationError = relationValueError(fields, finalized.data, paths, {
218
+ user: req.user,
219
+ operation: "update",
220
+ });
214
221
  if (relationError) {
215
222
  return fail("tool_denied", relationError);
216
223
  }
@@ -1,6 +1,7 @@
1
1
  import { compileOperations } from "../form/compile.js";
2
2
  import { hashFormRevision } from "../form/hash.js";
3
3
  import { isFormOperation } from "../form/operations.js";
4
+ import { relationValueError } from "../form/relation-shape.js";
4
5
  import { loadRichText } from "../form/rich-text.js";
5
6
  import { finalizeCompiled, openRichText } from "../form/shape.js";
6
7
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
@@ -95,6 +96,14 @@ export async function decideChange(req, options, input) {
95
96
  await consume(req, record.id, "failed");
96
97
  return { ok: false, error: "tool_denied", message: finalized.error };
97
98
  }
99
+ const relationError = relationValueError(fields, finalized.data, operations.map((operation) => operation.path), {
100
+ user: req.user,
101
+ operation: record.capability === "draft.create" ? "create" : "update",
102
+ });
103
+ if (relationError) {
104
+ await consume(req, record.id, "failed");
105
+ return { ok: false, error: "tool_denied", message: relationError };
106
+ }
98
107
  const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
99
108
  if (!relations.ok) {
100
109
  await consume(req, record.id, "failed");
@@ -0,0 +1,7 @@
1
+ import type { FieldNode } from "../schema/fields.ts";
2
+ import type { ProposalLine } from "./propose.ts";
3
+ /** Review block fields by ID so inserts and moves cannot mislabel other changes in the same list. */
4
+ export declare function newBlockReview(fields: FieldNode[], current: Record<string, unknown>, after: Record<string, unknown>, language?: "en" | "sv", labels?: Map<string, string>): {
5
+ lines: ProposalLine[];
6
+ containers: Set<string>;
7
+ };
@@ -0,0 +1,97 @@
1
+ import { humanDiff } from "../form/diff.js";
2
+ import { fieldPathInfo } from "../form/field-label.js";
3
+ import { getAtPath, parseFieldPath } from "../form/path.js";
4
+ /** Review block fields by ID so inserts and moves cannot mislabel other changes in the same list. */
5
+ export function newBlockReview(fields, current, after, language = "en", labels) {
6
+ const lines = [];
7
+ const containers = new Set();
8
+ const visit = (scope, before, value, prefix = "", schemaPrefix = "", added = false, insideBlock = false) => {
9
+ for (const field of scope) {
10
+ // Named tabs are flattened in the schema but retain their name in each field path.
11
+ const relative = field.path.slice(schemaPrefix ? schemaPrefix.length + 1 : 0);
12
+ const path = prefix ? `${prefix}.${relative}` : relative;
13
+ const previous = getAtPath(before, parseFieldPath(relative));
14
+ const next = getAtPath(value, parseFieldPath(relative));
15
+ if (!added && !insideBlock && (next === undefined || next === null))
16
+ continue;
17
+ if ((field.type === "blocks" || field.type === "array") && Array.isArray(next)) {
18
+ if (field.type === "blocks")
19
+ containers.add(path);
20
+ for (const [index, row] of next.entries()) {
21
+ if (!isRecord(row))
22
+ continue;
23
+ const oldRow = Array.isArray(previous)
24
+ ? row.id === undefined
25
+ ? previous[index]
26
+ : previous.find((entry) => isRecord(entry) && entry.id === row.id)
27
+ : undefined;
28
+ const newBlock = field.type === "blocks" && row.id !== undefined && !oldRow;
29
+ const children = field.type === "blocks"
30
+ ? field.blocks?.find((block) => block.slug === row.blockType)?.fields
31
+ : field.fields;
32
+ const start = lines.length;
33
+ visit(children ?? [], oldRow, row, `${path}.${index}`, field.path, added || newBlock, insideBlock || field.type === "blocks");
34
+ if (newBlock) {
35
+ if (lines.length === start) {
36
+ lines.push({
37
+ path: `${path}.${index}`,
38
+ groups: fieldPathInfo(fields, `${path}.${index}.__change`, after, language).groups,
39
+ text: language === "sv" ? "Nytt block" : "New block",
40
+ });
41
+ }
42
+ containers.add(path);
43
+ }
44
+ }
45
+ }
46
+ else if (field.fields) {
47
+ visit(field.fields, previous, next, path, field.path, added, insideBlock);
48
+ }
49
+ else if ((added && next !== undefined && next !== null) ||
50
+ (insideBlock && !added && JSON.stringify(previous) !== JSON.stringify(next))) {
51
+ const { label, context, groups } = fieldPathInfo(fields, path, after, language);
52
+ const text = humanDiff({ value: previous }, [
53
+ next === undefined
54
+ ? { op: "unset", path: "value" }
55
+ : { op: "set", path: "value", value: next },
56
+ ], language, labels, () => label)[0]?.text ?? label;
57
+ lines.push({
58
+ path,
59
+ label,
60
+ context,
61
+ groups,
62
+ text,
63
+ ...(field.type === "richText"
64
+ ? { richText: { before: added ? undefined : previous, after: next } }
65
+ : added
66
+ ? { value: displayValue(next, field, labels) }
67
+ : field.type === "text" || field.type === "textarea"
68
+ ? {
69
+ edit: {
70
+ label,
71
+ before: typeof previous === "string" ? previous : "",
72
+ value: typeof next === "string" ? next : "",
73
+ },
74
+ }
75
+ : {}),
76
+ });
77
+ }
78
+ }
79
+ };
80
+ visit(fields, current, after);
81
+ return { lines, containers };
82
+ }
83
+ function displayValue(value, field, labels) {
84
+ if (field.type === "relationship" || field.type === "upload") {
85
+ const name = (entry) => {
86
+ const id = isRecord(entry)
87
+ ? (entry.id ?? (isRecord(entry.value) ? entry.value.id : entry.value))
88
+ : entry;
89
+ return labels?.get(String(id)) ?? String(id);
90
+ };
91
+ return Array.isArray(value) ? value.map(name).join(", ") : name(value);
92
+ }
93
+ return typeof value === "string" ? value : JSON.stringify(value);
94
+ }
95
+ function isRecord(value) {
96
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
97
+ }
@@ -32,6 +32,8 @@ export type ProposalLine = {
32
32
  before: unknown;
33
33
  after: unknown;
34
34
  };
35
+ /** Full read-only value of a field supplied inside a new block. */
36
+ value?: string;
35
37
  edit?: {
36
38
  label: string;
37
39
  before: string;
@@ -1,3 +1,4 @@
1
+ import { hashBatchRevision } from "../form/batch-revision.js";
1
2
  import { changedPaths } from "../form/changed-paths.js";
2
3
  import { compileOperations } from "../form/compile.js";
3
4
  import { humanDiff, richTextLines } from "../form/diff.js";
@@ -16,6 +17,7 @@ import { hasCapability, resolveAllowlist } from "./allowlist.js";
16
17
  import { writeAudit } from "./audit.js";
17
18
  import { readDocument } from "./content.js";
18
19
  import { localApi } from "./local-api.js";
20
+ import { newBlockReview } from "./new-block-review.js";
19
21
  import { labelRelations, verifyDocumentRelations } from "./relations.js";
20
22
  /** Long enough to look at a change in the preview before keeping it. */
21
23
  const TTL_MS = 30 * 60_000;
@@ -68,7 +70,10 @@ export async function proposeChange(req, options, input) {
68
70
  if (!finalized.ok) {
69
71
  return { ok: false, error: "tool_denied", message: finalized.error };
70
72
  }
71
- const relationError = relationValueError(fields, finalized.data, changedPaths(typedOperations));
73
+ const relationError = relationValueError(fields, finalized.data, typedOperations.map((operation) => operation.path), {
74
+ user: req.user,
75
+ operation: input.capability === "draft.create" ? "create" : "update",
76
+ });
72
77
  if (relationError) {
73
78
  return { ok: false, error: "tool_denied", message: relationError };
74
79
  }
@@ -85,7 +90,11 @@ export async function proposeChange(req, options, input) {
85
90
  if (!relations.ok) {
86
91
  return { ok: false, error: "tool_denied", message: relations.message };
87
92
  }
88
- const formRevision = input.capability === "draft.create" ? undefined : await hashFormRevision(current);
93
+ const formRevision = input.capability === "draft.create"
94
+ ? undefined
95
+ : input.capability === "content.update"
96
+ ? await hashBatchRevision(current, fields)
97
+ : await hashFormRevision(current);
89
98
  const canonicalHash = await hashFormRevision(typedOperations);
90
99
  const nonce = crypto.randomUUID();
91
100
  const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
@@ -93,8 +102,9 @@ export async function proposeChange(req, options, input) {
93
102
  const setIds = new Set(typedOperations.flatMap((operation) => operation.op === "set"
94
103
  ? [operation.value, getAtPath(current, parseFieldPath(operation.path))].flatMap(idsIn)
95
104
  : []));
96
- const labels = setIds.size
97
- ? await labelRelations(req, fields, [finalized.data, current], setIds)
105
+ const addsBlocks = typedOperations.some((operation) => operation.op === "blocks.insert" || operation.op === "blocks.replace");
106
+ const labels = setIds.size || addsBlocks
107
+ ? await labelRelations(req, fields, [finalized.data, current], addsBlocks ? undefined : setIds)
98
108
  : undefined;
99
109
  const inRichText = (operation) => "path" in operation &&
100
110
  richTextBefore.some(({ path }) => operation.path === path || operation.path.startsWith(`${path}.`));
@@ -103,8 +113,23 @@ export async function proposeChange(req, options, input) {
103
113
  ...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels, (path) => fieldLabel(path).full),
104
114
  ...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language, (path) => fieldLabel(path).full)),
105
115
  ];
106
- const lines = diff
107
- .filter((line) => !richTextBefore.some(({ path }) => path === line.path))
116
+ const added = addsBlocks
117
+ ? newBlockReview(fields, current, finalized.data, input.language, labels)
118
+ : { lines: [], containers: new Set() };
119
+ const addedPaths = new Set(added.lines.map((line) => line.path));
120
+ const reviewedBlockField = (path) => {
121
+ const field = fieldAtPath(fields, path, finalized.data) ?? fieldAtPath(fields, path, current);
122
+ return (field &&
123
+ !field.fields &&
124
+ !field.blocks &&
125
+ [...added.containers].some((container) => path.startsWith(`${container}.`)));
126
+ };
127
+ const reviewDiff = humanDiff(current, typedOperations.filter((operation) => !inRichText(operation) &&
128
+ !((operation.op === "set" || operation.op === "unset") &&
129
+ reviewedBlockField(operation.path)) &&
130
+ !(operation.op === "blocks.insert" && added.containers.has(operation.path))), input.language, labels, (path) => fieldLabel(path).full);
131
+ const lines = reviewDiff
132
+ .filter((line) => !addedPaths.has(line.path))
108
133
  .map((line) => {
109
134
  const { context, label, groups } = fieldLabel(line.path);
110
135
  return {
@@ -116,6 +141,8 @@ export async function proposeChange(req, options, input) {
116
141
  };
117
142
  });
118
143
  for (const { path } of richTextBefore) {
144
+ if (addedPaths.has(path) || reviewedBlockField(path))
145
+ continue;
119
146
  const { context, label, groups } = fieldLabel(path);
120
147
  lines.push({
121
148
  path,
@@ -132,6 +159,7 @@ export async function proposeChange(req, options, input) {
132
159
  },
133
160
  });
134
161
  }
162
+ lines.push(...added.lines);
135
163
  let created;
136
164
  try {
137
165
  created = await localApi(req).create({
@@ -305,7 +333,11 @@ function idsIn(value) {
305
333
  return [String(value)];
306
334
  }
307
335
  if (Array.isArray(value)) {
308
- return value.flatMap((entry) => entry && typeof entry === "object" && "id" in entry ? idsIn(entry.id) : idsIn(entry));
336
+ return value.flatMap(idsIn);
337
+ }
338
+ if (value && typeof value === "object") {
339
+ const relation = value;
340
+ return idsIn(relation.id ?? relation.value);
309
341
  }
310
342
  return [];
311
343
  }
@@ -1,7 +1,10 @@
1
1
  import type { Endpoint } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ import type { ChatMessage } from "../runtime/agent.ts";
3
4
  export declare function createChatEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
4
5
  /** Only a short list of form paths; the prompt names them as data. */
5
6
  export declare function sanitizeOpenProposal(value: unknown): {
6
7
  changedPaths: string[];
7
8
  } | undefined;
9
+ /** No remote fetches: only bounded, typed image bytes on user messages. */
10
+ export declare function validChatImages(messages: ChatMessage[]): boolean;
@@ -1,3 +1,4 @@
1
+ import { imageByteLength, isChatImage, MAX_CHAT_IMAGES, MAX_CHAT_IMAGES_BYTES, } from "../chat-images.js";
1
2
  import { ENDPOINT_PREFIX } from "../package-name.js";
2
3
  import { runAgentTurn } from "../runtime/agent.js";
3
4
  import { beginInflight, endInflight } from "../runtime/inflight.js";
@@ -34,18 +35,26 @@ export function createChatEndpoint(options) {
34
35
  if (typeof req.json !== "function") {
35
36
  return Response.json({ error: "tool_denied" }, { status: 400 });
36
37
  }
38
+ if (Number(req.headers.get("content-length")) > 4 * 1024 * 1024)
39
+ return Response.json({ error: "tool_denied", message: "Chat request is too large." }, { status: 413 });
37
40
  body = (await req.json());
41
+ if (Buffer.byteLength(JSON.stringify(body)) > 4 * 1024 * 1024)
42
+ return Response.json({ error: "tool_denied", message: "Chat request is too large." }, { status: 413 });
38
43
  }
39
44
  catch {
40
45
  return Response.json({ error: "tool_denied" }, { status: 400 });
41
46
  }
42
- if (typeof body.message !== "string" || body.message.trim() === "") {
47
+ if (!body || typeof body !== "object" || typeof body.message !== "string") {
43
48
  return Response.json({ error: "tool_denied" }, { status: 400 });
44
49
  }
45
50
  const locale = nonEmpty(body.bridge?.locale) ?? nonEmpty(body.locale) ?? defaultLocale(req);
46
51
  const messages = Array.isArray(body.messages)
47
- ? body.messages.filter(isChatMessage)
52
+ ? body.messages.filter(isChatMessage).slice(-20)
48
53
  : [{ role: "user", text: body.message }];
54
+ if (!validChatImages(messages) ||
55
+ (!body.message.trim() && !messages.at(-1)?.images?.length)) {
56
+ return Response.json({ error: "tool_denied", message: "Invalid or oversized image attachments." }, { status: 400 });
57
+ }
49
58
  const language = resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), nonEmpty(body.uiLanguage));
50
59
  const run = (onEvent) => runAgentTurn(req, options, {
51
60
  onEvent,
@@ -155,3 +164,23 @@ function isChatMessage(value) {
155
164
  return ((record.role === "user" || record.role === "assistant" || record.role === "system") &&
156
165
  typeof record.text === "string");
157
166
  }
167
+ /** No remote fetches: only bounded, typed image bytes on user messages. */
168
+ export function validChatImages(messages) {
169
+ let bytes = 0;
170
+ for (const message of messages) {
171
+ if (message.images === undefined)
172
+ continue;
173
+ if (message.role !== "user" ||
174
+ !Array.isArray(message.images) ||
175
+ message.images.length > MAX_CHAT_IMAGES)
176
+ return false;
177
+ for (const image of message.images) {
178
+ if (!isChatImage(image))
179
+ return false;
180
+ bytes += imageByteLength(image.data);
181
+ if (bytes > MAX_CHAT_IMAGES_BYTES)
182
+ return false;
183
+ }
184
+ }
185
+ return true;
186
+ }
@@ -1,6 +1,7 @@
1
1
  import { decideChange } from "../domain/decide.js";
2
2
  import { guardMutationRequest, readJsonBody } from "../domain/guard.js";
3
3
  import { ENDPOINT_PREFIX } from "../package-name.js";
4
+ import { createAssistantDebug } from "../runtime/debug.js";
4
5
  export function createDecideEndpoint(options) {
5
6
  return {
6
7
  path: `${ENDPOINT_PREFIX}/decide`,
@@ -14,20 +15,40 @@ export function createDecideEndpoint(options) {
14
15
  if (!body?.action || !body.approvalId || !body.nonce || !body.locale) {
15
16
  return Response.json({ error: "tool_denied" }, { status: 400 });
16
17
  }
17
- const result = await decideChange(req, options, {
18
+ const debug = createAssistantDebug(req);
19
+ debug.write("decision.start", {
20
+ actor: req.user?.id,
18
21
  action: body.action,
19
22
  approvalId: body.approvalId,
20
- nonce: body.nonce,
21
23
  collection: body.collection,
22
24
  global: body.global,
23
25
  documentId: body.documentId,
24
26
  locale: body.locale,
25
- formData: body.formData,
26
27
  });
27
- if (!result.ok) {
28
- return Response.json({ error: result.error, message: result.message }, { status: 400 });
28
+ try {
29
+ const result = await decideChange(req, options, {
30
+ action: body.action,
31
+ approvalId: body.approvalId,
32
+ nonce: body.nonce,
33
+ collection: body.collection,
34
+ global: body.global,
35
+ documentId: body.documentId,
36
+ locale: body.locale,
37
+ formData: body.formData,
38
+ });
39
+ debug.write("decision.result", result);
40
+ if (!result.ok) {
41
+ return Response.json({ error: result.error, message: result.message }, { status: 400 });
42
+ }
43
+ return Response.json(result);
44
+ }
45
+ catch (error) {
46
+ debug.write("decision.error", error);
47
+ throw error;
48
+ }
49
+ finally {
50
+ await debug.flush();
29
51
  }
30
- return Response.json(result);
31
52
  },
32
53
  };
33
54
  }
@@ -0,0 +1,6 @@
1
+ import type { FieldNode } from "../schema/fields.ts";
2
+ /**
3
+ * A first SQL draft may materialize absent localized columns as null. Hash their empty values
4
+ * consistently, without altering saved content or relaxing checks on nonempty fields or row ids.
5
+ */
6
+ export declare function hashBatchRevision(data: Record<string, unknown>, fields: FieldNode[]): Promise<string>;
@@ -0,0 +1,39 @@
1
+ import { hashFormRevision } from "./hash.js";
2
+ /**
3
+ * A first SQL draft may materialize absent localized columns as null. Hash their empty values
4
+ * consistently, without altering saved content or relaxing checks on nonempty fields or row ids.
5
+ */
6
+ export async function hashBatchRevision(data, fields) {
7
+ return `batch-v1:${await hashFormRevision(normalizeRecord(data, fields))}`;
8
+ }
9
+ function normalizeRecord(data, fields) {
10
+ const normalized = { ...data };
11
+ for (const field of fields) {
12
+ const value = data[field.name];
13
+ if (field.type === "group" && (value == null || isRecord(value))) {
14
+ normalized[field.name] = normalizeRecord(isRecord(value) ? value : {}, field.fields ?? []);
15
+ }
16
+ else if (field.type === "array" && Array.isArray(value)) {
17
+ normalized[field.name] = value.map((row) => isRecord(row) ? normalizeRecord(row, field.fields ?? []) : row);
18
+ }
19
+ else if (field.type === "blocks" && Array.isArray(value)) {
20
+ normalized[field.name] = value.map((row) => {
21
+ if (!isRecord(row))
22
+ return row;
23
+ const block = field.blocks?.find((entry) => entry.slug === row.blockType);
24
+ return block ? normalizeRecord(row, block.fields) : row;
25
+ });
26
+ }
27
+ else {
28
+ normalized[field.name] =
29
+ value === undefined ||
30
+ ((field.type === "text" || field.type === "textarea") && value === "")
31
+ ? null
32
+ : value;
33
+ }
34
+ }
35
+ return normalized;
36
+ }
37
+ function isRecord(value) {
38
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
39
+ }
package/dist/form/diff.js CHANGED
@@ -46,8 +46,9 @@ export function humanDiff(current, operations, language = "en", labels, fieldLab
46
46
  const nameOf = (value) => {
47
47
  const id = typeof value === "string" || typeof value === "number"
48
48
  ? String(value)
49
- : value && typeof value === "object" && "id" in value
50
- ? String(value.id)
49
+ : value && typeof value === "object" && ("id" in value || "value" in value)
50
+ ? String(value.id ??
51
+ value.value)
51
52
  : undefined;
52
53
  return id !== undefined ? labels?.get(id) : undefined;
53
54
  };
@@ -1,9 +1,14 @@
1
+ import type { PayloadRequest } from "payload";
1
2
  import { type FieldNode } from "../schema/fields.ts";
2
3
  /**
3
- * Checks the upload and relationship fields a proposal changes (`paths`, as `changedPaths` gives
4
- * them): one id for a single field; for a `hasMany` field a list of ids without repeats, within its
5
- * `minRows` and `maxRows` (an empty list is allowed). Fields pointing at several collections are
6
- * refused. Untouched fields are not checked, so a value the editor saved cannot block a proposal.
4
+ * Checks the upload and relationship fields a proposal changes (`paths` from the operations),
5
+ * including relations inside changed groups, rows and blocks. A single-collection
6
+ * polymorphic relation still needs { relationTo, value }. Missing required relationships in a
7
+ * changed group are checked against Payload's trusted field conditions and primitive defaults.
8
+ * Fields pointing at several collections are refused. Unrelated fields are not checked.
7
9
  * Whether the ids exist and are readable is `verifyDocumentRelations`' job.
8
10
  */
9
- export declare function relationValueError(fields: FieldNode[], data: Record<string, unknown>, paths: string[]): string | null;
11
+ export declare function relationValueError(fields: FieldNode[], data: Record<string, unknown>, paths: string[], context?: {
12
+ user?: PayloadRequest["user"];
13
+ operation?: "create" | "update";
14
+ }): string | null;