@camox/api 0.2.0-alpha.5
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.
- package/LICENSE.md +110 -0
- package/README.md +21 -0
- package/package.json +54 -0
- package/src/authorization.ts +110 -0
- package/src/db.ts +45 -0
- package/src/durable-objects/ai-job-scheduler.ts +135 -0
- package/src/durable-objects/project-room.ts +16 -0
- package/src/index.ts +125 -0
- package/src/lib/broadcast-invalidation.ts +17 -0
- package/src/lib/content-markdown.ts +117 -0
- package/src/lib/cross-domain.ts +186 -0
- package/src/lib/lexical-state.ts +196 -0
- package/src/lib/query-keys.ts +36 -0
- package/src/lib/resolve-environment.ts +218 -0
- package/src/lib/schedule-ai-job.ts +21 -0
- package/src/lib/slug.ts +42 -0
- package/src/middleware.ts +10 -0
- package/src/orpc.ts +65 -0
- package/src/router.ts +19 -0
- package/src/routes/auth.ts +110 -0
- package/src/routes/block-definitions.ts +216 -0
- package/src/routes/blocks.ts +800 -0
- package/src/routes/files.ts +463 -0
- package/src/routes/layouts.ts +164 -0
- package/src/routes/pages.ts +818 -0
- package/src/routes/projects.ts +267 -0
- package/src/routes/repeatable-items.ts +463 -0
- package/src/schema.ts +310 -0
- package/src/types.ts +29 -0
- package/src/worker.ts +3 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server";
|
|
2
|
+
import { chat } from "@tanstack/ai";
|
|
3
|
+
import { createOpenRouterText } from "@tanstack/ai-openrouter";
|
|
4
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
5
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
6
|
+
import { outdent } from "outdent";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import { assertBlockAccess, assertRepeatableItemAccess } from "../authorization";
|
|
10
|
+
import type { Database } from "../db";
|
|
11
|
+
import { broadcastInvalidation } from "../lib/broadcast-invalidation";
|
|
12
|
+
import { queryKeys } from "../lib/query-keys";
|
|
13
|
+
import { scheduleAiJob } from "../lib/schedule-ai-job";
|
|
14
|
+
import { pub, authed } from "../orpc";
|
|
15
|
+
import { blocks, files, repeatableItems } from "../schema";
|
|
16
|
+
import { collectFileIds } from "./pages";
|
|
17
|
+
|
|
18
|
+
function comparePositions(a: string, b: string): number {
|
|
19
|
+
if (a < b) return -1;
|
|
20
|
+
if (a > b) return 1;
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Find the last index where item.position <= target in a sorted array. */
|
|
25
|
+
function findLastIndexLe<T extends { position: string }>(items: T[], target: string): number {
|
|
26
|
+
let result = -1;
|
|
27
|
+
for (let i = 0; i < items.length; i++) {
|
|
28
|
+
if (items[i].position <= target) result = i;
|
|
29
|
+
else break;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- AI Executor ---
|
|
35
|
+
|
|
36
|
+
async function generateObjectSummary(
|
|
37
|
+
apiKey: string,
|
|
38
|
+
options: { type: string; markdown: string; previousSummary?: string },
|
|
39
|
+
) {
|
|
40
|
+
const stabilityBlock = options.previousSummary
|
|
41
|
+
? outdent`
|
|
42
|
+
|
|
43
|
+
<previous_summary>${options.previousSummary}</previous_summary>
|
|
44
|
+
<stability_instruction>
|
|
45
|
+
A summary was previously generated for this content.
|
|
46
|
+
Return the SAME summary unless it is no longer accurate.
|
|
47
|
+
Only change it if the content has meaningfully changed.
|
|
48
|
+
</stability_instruction>
|
|
49
|
+
`
|
|
50
|
+
: "";
|
|
51
|
+
|
|
52
|
+
return await chat({
|
|
53
|
+
adapter: createOpenRouterText("openai/gpt-oss-20b", apiKey),
|
|
54
|
+
stream: false,
|
|
55
|
+
messages: [
|
|
56
|
+
{
|
|
57
|
+
role: "user",
|
|
58
|
+
content: outdent`
|
|
59
|
+
<instruction>
|
|
60
|
+
Generate a concise summary for a piece of website content.
|
|
61
|
+
</instruction>
|
|
62
|
+
|
|
63
|
+
<constraints>
|
|
64
|
+
- MAXIMUM 4 WORDS
|
|
65
|
+
- Capture the main idea or purpose
|
|
66
|
+
- Be descriptive and specific to the content type
|
|
67
|
+
- Use sentence case (only capitalize the first word and proper nouns)
|
|
68
|
+
- Don't use markdown, just plain text
|
|
69
|
+
- Don't use punctuation
|
|
70
|
+
- Use abbreviations or acronyms where appropriate
|
|
71
|
+
</constraints>
|
|
72
|
+
|
|
73
|
+
<context>
|
|
74
|
+
<type>${options.type}</type>
|
|
75
|
+
<content>${options.markdown}</content>
|
|
76
|
+
</context>
|
|
77
|
+
${stabilityBlock}
|
|
78
|
+
|
|
79
|
+
<examples>
|
|
80
|
+
<example>
|
|
81
|
+
<type>paragraph</type>
|
|
82
|
+
<content>{"text": "This is a description of how our service works in detail."}</content>
|
|
83
|
+
<output>Service explanation details</output>
|
|
84
|
+
</example>
|
|
85
|
+
|
|
86
|
+
<example>
|
|
87
|
+
<type>button</type>
|
|
88
|
+
<content>{"text": "Submit Form", "action": "submit"}</content>
|
|
89
|
+
<output>Submit form button</output>
|
|
90
|
+
</example>
|
|
91
|
+
</examples>
|
|
92
|
+
|
|
93
|
+
<format>
|
|
94
|
+
Return only the summary text, nothing else.
|
|
95
|
+
</format>
|
|
96
|
+
`,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generates and stores a summary for a repeatable item.
|
|
104
|
+
* Returns `{ blockId }` so the caller can cascade to block summary regeneration.
|
|
105
|
+
*/
|
|
106
|
+
export async function executeRepeatableItemSummary(
|
|
107
|
+
db: Database,
|
|
108
|
+
apiKey: string,
|
|
109
|
+
itemId: number,
|
|
110
|
+
): Promise<{ blockId: number } | null> {
|
|
111
|
+
const item = await db.select().from(repeatableItems).where(eq(repeatableItems.id, itemId)).get();
|
|
112
|
+
if (!item) return null;
|
|
113
|
+
|
|
114
|
+
const block = await db.select().from(blocks).where(eq(blocks.id, item.blockId)).get();
|
|
115
|
+
if (!block) return null;
|
|
116
|
+
|
|
117
|
+
const summary = await generateObjectSummary(apiKey, {
|
|
118
|
+
type: block.type,
|
|
119
|
+
markdown: JSON.stringify(item.content),
|
|
120
|
+
previousSummary: item.summary,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await db
|
|
124
|
+
.update(repeatableItems)
|
|
125
|
+
.set({ summary, updatedAt: Date.now() })
|
|
126
|
+
.where(eq(repeatableItems.id, itemId));
|
|
127
|
+
|
|
128
|
+
if (summary !== item.summary) {
|
|
129
|
+
return { blockId: item.blockId };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Procedures ---
|
|
136
|
+
|
|
137
|
+
const nestedItemSeedSchema = z.object({
|
|
138
|
+
tempId: z.string(),
|
|
139
|
+
parentTempId: z.string().nullable(),
|
|
140
|
+
fieldName: z.string(),
|
|
141
|
+
content: z.unknown(),
|
|
142
|
+
position: z.string(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const createItemSchema = z.object({
|
|
146
|
+
blockId: z.number(),
|
|
147
|
+
parentItemId: z.number().nullable().optional(),
|
|
148
|
+
fieldName: z.string(),
|
|
149
|
+
content: z.unknown(),
|
|
150
|
+
afterPosition: z.string().nullable().optional(),
|
|
151
|
+
nestedItems: z.array(nestedItemSeedSchema).optional(),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const create = authed.input(createItemSchema).handler(async ({ context, input }) => {
|
|
155
|
+
const { blockId, parentItemId, fieldName, content, afterPosition, nestedItems } = input;
|
|
156
|
+
const access = await assertBlockAccess(context.db, blockId, context.user.id);
|
|
157
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
158
|
+
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
|
|
161
|
+
// Get siblings to determine correct position
|
|
162
|
+
const siblings = (
|
|
163
|
+
await context.db
|
|
164
|
+
.select()
|
|
165
|
+
.from(repeatableItems)
|
|
166
|
+
.where(and(eq(repeatableItems.blockId, blockId), eq(repeatableItems.fieldName, fieldName)))
|
|
167
|
+
).sort((a, b) => comparePositions(a.position, b.position));
|
|
168
|
+
|
|
169
|
+
let position: string;
|
|
170
|
+
if (afterPosition === undefined || afterPosition === null) {
|
|
171
|
+
const lastItem = siblings[siblings.length - 1];
|
|
172
|
+
position = generateKeyBetween(lastItem?.position ?? null, null);
|
|
173
|
+
} else if (afterPosition === "") {
|
|
174
|
+
const firstItem = siblings[0];
|
|
175
|
+
position = generateKeyBetween(null, firstItem?.position ?? null);
|
|
176
|
+
} else {
|
|
177
|
+
const afterIndex = findLastIndexLe(siblings, afterPosition!);
|
|
178
|
+
const nextItem = afterIndex >= 0 ? siblings[afterIndex + 1] : siblings[0];
|
|
179
|
+
position = generateKeyBetween(
|
|
180
|
+
afterIndex >= 0 ? siblings[afterIndex].position : null,
|
|
181
|
+
nextItem?.position ?? null,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = await context.db
|
|
186
|
+
.insert(repeatableItems)
|
|
187
|
+
.values({
|
|
188
|
+
blockId,
|
|
189
|
+
parentItemId: parentItemId ?? null,
|
|
190
|
+
fieldName,
|
|
191
|
+
content,
|
|
192
|
+
summary: "",
|
|
193
|
+
position,
|
|
194
|
+
createdAt: now,
|
|
195
|
+
updatedAt: now,
|
|
196
|
+
})
|
|
197
|
+
.returning()
|
|
198
|
+
.get();
|
|
199
|
+
|
|
200
|
+
// Insert client-provided nested item seeds
|
|
201
|
+
if (nestedItems && nestedItems.length > 0) {
|
|
202
|
+
const tempIdToRealId = new Map<string, number>();
|
|
203
|
+
|
|
204
|
+
for (const seed of nestedItems) {
|
|
205
|
+
// null parentTempId means child of the item being created
|
|
206
|
+
const seedParentId = seed.parentTempId
|
|
207
|
+
? (tempIdToRealId.get(seed.parentTempId) ?? result.id)
|
|
208
|
+
: result.id;
|
|
209
|
+
const inserted = await context.db
|
|
210
|
+
.insert(repeatableItems)
|
|
211
|
+
.values({
|
|
212
|
+
blockId,
|
|
213
|
+
parentItemId: seedParentId,
|
|
214
|
+
fieldName: seed.fieldName,
|
|
215
|
+
content: seed.content,
|
|
216
|
+
summary: "",
|
|
217
|
+
position: seed.position,
|
|
218
|
+
createdAt: now,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
})
|
|
221
|
+
.returning()
|
|
222
|
+
.get();
|
|
223
|
+
tempIdToRealId.set(seed.tempId, inserted.id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
|
|
228
|
+
entityTable: "repeatableItems",
|
|
229
|
+
entityId: result.id,
|
|
230
|
+
type: "summary",
|
|
231
|
+
delayMs: 0,
|
|
232
|
+
});
|
|
233
|
+
// Granular invalidation: refetch the parent block bundle (includes new item)
|
|
234
|
+
broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
|
|
235
|
+
queryKeys.blocks.get(blockId),
|
|
236
|
+
queryKeys.blocks.getUsageCounts,
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
return result;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const updateContent = authed
|
|
243
|
+
.input(z.object({ id: z.number(), content: z.unknown() }))
|
|
244
|
+
.handler(async ({ context, input }) => {
|
|
245
|
+
const { id, content } = input;
|
|
246
|
+
const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
|
|
247
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
248
|
+
|
|
249
|
+
// Merge partial content into existing content (frontend sends single-field patches)
|
|
250
|
+
const merged = {
|
|
251
|
+
...(access.item.content as Record<string, unknown>),
|
|
252
|
+
...(content as Record<string, unknown>),
|
|
253
|
+
};
|
|
254
|
+
const result = await context.db
|
|
255
|
+
.update(repeatableItems)
|
|
256
|
+
.set({ content: merged, updatedAt: Date.now() })
|
|
257
|
+
.where(eq(repeatableItems.id, id))
|
|
258
|
+
.returning()
|
|
259
|
+
.get();
|
|
260
|
+
|
|
261
|
+
scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
|
|
262
|
+
entityTable: "repeatableItems",
|
|
263
|
+
entityId: id,
|
|
264
|
+
type: "summary",
|
|
265
|
+
delayMs: 5000,
|
|
266
|
+
});
|
|
267
|
+
// Granular invalidation: only refetch the parent block bundle
|
|
268
|
+
broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
|
|
269
|
+
queryKeys.blocks.get(access.item.blockId),
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
return result;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const updatePosition = authed
|
|
276
|
+
.input(
|
|
277
|
+
z.object({
|
|
278
|
+
id: z.number(),
|
|
279
|
+
afterPosition: z.string().nullable().optional(),
|
|
280
|
+
beforePosition: z.string().nullable().optional(),
|
|
281
|
+
}),
|
|
282
|
+
)
|
|
283
|
+
.handler(async ({ context, input }) => {
|
|
284
|
+
const { id, afterPosition, beforePosition } = input;
|
|
285
|
+
const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
|
|
286
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
287
|
+
|
|
288
|
+
const item = access.item;
|
|
289
|
+
const siblings = (
|
|
290
|
+
await context.db
|
|
291
|
+
.select()
|
|
292
|
+
.from(repeatableItems)
|
|
293
|
+
.where(
|
|
294
|
+
and(
|
|
295
|
+
eq(repeatableItems.blockId, item.blockId),
|
|
296
|
+
eq(repeatableItems.fieldName, item.fieldName),
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
.filter((s) => s.id !== id)
|
|
301
|
+
.sort((a, b) => comparePositions(a.position, b.position));
|
|
302
|
+
|
|
303
|
+
const after = afterPosition || null;
|
|
304
|
+
const before = beforePosition || null;
|
|
305
|
+
|
|
306
|
+
let position: string;
|
|
307
|
+
if (!after && !before) {
|
|
308
|
+
const last = siblings[siblings.length - 1];
|
|
309
|
+
position = generateKeyBetween(last?.position ?? null, null);
|
|
310
|
+
} else if (!after) {
|
|
311
|
+
const firstIdx = siblings.findIndex((b) => b.position >= before!);
|
|
312
|
+
position = generateKeyBetween(null, siblings[firstIdx]?.position ?? null);
|
|
313
|
+
} else {
|
|
314
|
+
const afterIdx = findLastIndexLe(siblings, after);
|
|
315
|
+
const nextPos = siblings[afterIdx + 1]?.position ?? null;
|
|
316
|
+
position = generateKeyBetween(siblings[afterIdx]?.position ?? null, nextPos);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const result = await context.db
|
|
320
|
+
.update(repeatableItems)
|
|
321
|
+
.set({ position, updatedAt: Date.now() })
|
|
322
|
+
.where(eq(repeatableItems.id, id))
|
|
323
|
+
.returning()
|
|
324
|
+
.get();
|
|
325
|
+
// Granular invalidation: only refetch the parent block bundle
|
|
326
|
+
broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
|
|
327
|
+
queryKeys.blocks.get(access.item.blockId),
|
|
328
|
+
]);
|
|
329
|
+
return result;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const duplicate = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
333
|
+
const { id } = input;
|
|
334
|
+
const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
|
|
335
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
336
|
+
const original = access.item;
|
|
337
|
+
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
|
|
340
|
+
// Find the next sibling to insert between original and next
|
|
341
|
+
const siblings = (
|
|
342
|
+
await context.db
|
|
343
|
+
.select()
|
|
344
|
+
.from(repeatableItems)
|
|
345
|
+
.where(
|
|
346
|
+
and(
|
|
347
|
+
eq(repeatableItems.blockId, original.blockId),
|
|
348
|
+
eq(repeatableItems.fieldName, original.fieldName),
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
).sort((a, b) => comparePositions(a.position, b.position));
|
|
352
|
+
const originalIndex = siblings.findIndex((s) => s.id === id);
|
|
353
|
+
const nextItem = originalIndex >= 0 ? siblings[originalIndex + 1] : undefined;
|
|
354
|
+
const position = generateKeyBetween(original.position, nextItem?.position ?? null);
|
|
355
|
+
|
|
356
|
+
const result = await context.db
|
|
357
|
+
.insert(repeatableItems)
|
|
358
|
+
.values({
|
|
359
|
+
blockId: original.blockId,
|
|
360
|
+
fieldName: original.fieldName,
|
|
361
|
+
content: original.content,
|
|
362
|
+
summary: original.summary,
|
|
363
|
+
position,
|
|
364
|
+
createdAt: now,
|
|
365
|
+
updatedAt: now,
|
|
366
|
+
})
|
|
367
|
+
.returning()
|
|
368
|
+
.get();
|
|
369
|
+
// Granular invalidation: refetch the parent block bundle (includes new item)
|
|
370
|
+
broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
|
|
371
|
+
queryKeys.blocks.get(original.blockId),
|
|
372
|
+
queryKeys.blocks.getUsageCounts,
|
|
373
|
+
]);
|
|
374
|
+
return result;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const generateSummary = authed
|
|
378
|
+
.input(z.object({ id: z.number() }))
|
|
379
|
+
.handler(async ({ context, input }) => {
|
|
380
|
+
const { id } = input;
|
|
381
|
+
const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
|
|
382
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
383
|
+
|
|
384
|
+
const cascade = await executeRepeatableItemSummary(
|
|
385
|
+
context.db,
|
|
386
|
+
context.env.OPEN_ROUTER_API_KEY,
|
|
387
|
+
id,
|
|
388
|
+
);
|
|
389
|
+
if (cascade) {
|
|
390
|
+
scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
|
|
391
|
+
entityTable: "blocks",
|
|
392
|
+
entityId: cascade.blockId,
|
|
393
|
+
type: "summary",
|
|
394
|
+
delayMs: 5000,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Granular invalidation: refetch the parent block bundle (includes updated summary)
|
|
398
|
+
broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
|
|
399
|
+
queryKeys.blocks.get(access.item.blockId),
|
|
400
|
+
queryKeys.blocks.getUsageCounts,
|
|
401
|
+
]);
|
|
402
|
+
const updated = await context.db
|
|
403
|
+
.select()
|
|
404
|
+
.from(repeatableItems)
|
|
405
|
+
.where(eq(repeatableItems.id, id))
|
|
406
|
+
.get();
|
|
407
|
+
return updated;
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const deleteFn = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
411
|
+
const { id } = input;
|
|
412
|
+
const access = await assertRepeatableItemAccess(context.db, id, context.user.id);
|
|
413
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
414
|
+
|
|
415
|
+
const blockId = access.item.blockId;
|
|
416
|
+
const result = await context.db
|
|
417
|
+
.delete(repeatableItems)
|
|
418
|
+
.where(eq(repeatableItems.id, id))
|
|
419
|
+
.returning()
|
|
420
|
+
.get();
|
|
421
|
+
// Granular invalidation: refetch the parent block bundle (item removed)
|
|
422
|
+
broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
|
|
423
|
+
queryKeys.blocks.get(blockId),
|
|
424
|
+
queryKeys.blocks.getUsageCounts,
|
|
425
|
+
]);
|
|
426
|
+
return result;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const get = pub.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
430
|
+
const item = await context.db
|
|
431
|
+
.select()
|
|
432
|
+
.from(repeatableItems)
|
|
433
|
+
.where(eq(repeatableItems.id, input.id))
|
|
434
|
+
.get();
|
|
435
|
+
if (!item) throw new ORPCError("NOT_FOUND");
|
|
436
|
+
|
|
437
|
+
// Collect and fetch referenced files
|
|
438
|
+
const fileIds = new Set<number>();
|
|
439
|
+
collectFileIds(item.content as Record<string, unknown>, fileIds);
|
|
440
|
+
|
|
441
|
+
const fileRows =
|
|
442
|
+
fileIds.size > 0
|
|
443
|
+
? await context.db
|
|
444
|
+
.select()
|
|
445
|
+
.from(files)
|
|
446
|
+
.where(inArray(files.id, [...fileIds]))
|
|
447
|
+
: [];
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
item,
|
|
451
|
+
files: fileRows,
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
export const repeatableItemProcedures = {
|
|
456
|
+
get,
|
|
457
|
+
create,
|
|
458
|
+
updateContent,
|
|
459
|
+
updatePosition,
|
|
460
|
+
duplicate,
|
|
461
|
+
generateSummary,
|
|
462
|
+
delete: deleteFn,
|
|
463
|
+
};
|