@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.
@@ -0,0 +1,800 @@
1
+ import { ORPCError } from "@orpc/server";
2
+ import { chat } from "@tanstack/ai";
3
+ import { createOpenRouterText } from "@tanstack/ai-openrouter";
4
+ import { and, eq, or, sql, inArray } from "drizzle-orm";
5
+ import { generateKeyBetween } from "fractional-indexing";
6
+ import { outdent } from "outdent";
7
+ import { z } from "zod";
8
+
9
+ import { assertBlockAccess, assertPageAccess } from "../authorization";
10
+ import type { Database } from "../db";
11
+ import { broadcastInvalidation } from "../lib/broadcast-invalidation";
12
+ import { contentToMarkdown } from "../lib/content-markdown";
13
+ import { queryKeys } from "../lib/query-keys";
14
+ import { resolveEnvironment } from "../lib/resolve-environment";
15
+ import { scheduleAiJob } from "../lib/schedule-ai-job";
16
+ import { pub, authed } from "../orpc";
17
+ import {
18
+ blockDefinitions,
19
+ blocks,
20
+ files,
21
+ layouts,
22
+ member,
23
+ organizationTable,
24
+ pages,
25
+ projects,
26
+ repeatableItems,
27
+ } from "../schema";
28
+ import { collectFileIds } from "./pages";
29
+
30
+ // --- AI Executor ---
31
+
32
+ async function generateObjectSummary(
33
+ apiKey: string,
34
+ options: { type: string; markdown: string; previousSummary?: string },
35
+ ) {
36
+ const stabilityBlock = options.previousSummary
37
+ ? outdent`
38
+
39
+ <previous_summary>${options.previousSummary}</previous_summary>
40
+ <stability_instruction>
41
+ A summary was previously generated for this content.
42
+ Return the SAME summary unless it is no longer accurate.
43
+ Only change it if the content has meaningfully changed.
44
+ </stability_instruction>
45
+ `
46
+ : "";
47
+
48
+ return await chat({
49
+ adapter: createOpenRouterText("openai/gpt-oss-20b", apiKey),
50
+ stream: false,
51
+ messages: [
52
+ {
53
+ role: "user",
54
+ content: outdent`
55
+ <instruction>
56
+ Generate a concise summary for a piece of website content.
57
+ </instruction>
58
+
59
+ <constraints>
60
+ - MAXIMUM 4 WORDS
61
+ - Capture the main idea or purpose
62
+ - Be descriptive and specific to the content type
63
+ - Use sentence case (only capitalize the first word and proper nouns)
64
+ - Don't use markdown, just plain text
65
+ - Don't use punctuation
66
+ - Use abbreviations or acronyms where appropriate
67
+ </constraints>
68
+
69
+ <context>
70
+ <type>${options.type}</type>
71
+ <content>${options.markdown}</content>
72
+ </context>
73
+ ${stabilityBlock}
74
+
75
+ <examples>
76
+ <example>
77
+ <type>paragraph</type>
78
+ <content>{"text": "This is a description of how our service works in detail."}</content>
79
+ <output>Service explanation details</output>
80
+ </example>
81
+
82
+ <example>
83
+ <type>button</type>
84
+ <content>{"text": "Submit Form", "action": "submit"}</content>
85
+ <output>Submit form button</output>
86
+ </example>
87
+ </examples>
88
+
89
+ <format>
90
+ Return only the summary text, nothing else.
91
+ </format>
92
+ `,
93
+ },
94
+ ],
95
+ });
96
+ }
97
+
98
+ /** Recursively nest child items into their parent item's content. */
99
+ function nestChildItems(
100
+ allItems: { id: number; parentItemId: number | null; fieldName: string; content: unknown }[],
101
+ ) {
102
+ const childrenByParent = new Map<number, Map<string, typeof allItems>>();
103
+ for (const item of allItems) {
104
+ if (item.parentItemId === null) continue;
105
+ let fieldMap = childrenByParent.get(item.parentItemId);
106
+ if (!fieldMap) {
107
+ fieldMap = new Map();
108
+ childrenByParent.set(item.parentItemId, fieldMap);
109
+ }
110
+ const list = fieldMap.get(item.fieldName) ?? [];
111
+ list.push(item);
112
+ fieldMap.set(item.fieldName, list);
113
+ }
114
+ for (const item of allItems) {
115
+ const childFields = childrenByParent.get(item.id);
116
+ if (!childFields) continue;
117
+ const content = item.content as Record<string, unknown>;
118
+ for (const [fieldName, children] of childFields) {
119
+ content[fieldName] = children;
120
+ }
121
+ }
122
+ }
123
+
124
+ function comparePositions(a: string, b: string): number {
125
+ if (a < b) return -1;
126
+ if (a > b) return 1;
127
+ return 0;
128
+ }
129
+
130
+ function sortByPosition<T extends { position: string }>(items: T[]): T[] {
131
+ return items.sort((a, b) => comparePositions(a.position, b.position));
132
+ }
133
+
134
+ /** Find the last index where item.position <= target in a sorted array. */
135
+ function findLastIndexLe<T extends { position: string }>(items: T[], target: string): number {
136
+ let result = -1;
137
+ for (let i = 0; i < items.length; i++) {
138
+ if (items[i].position <= target) result = i;
139
+ else break;
140
+ }
141
+ return result;
142
+ }
143
+
144
+ async function assembleBlockContent(db: Database, blockId: number) {
145
+ const block = await db.select().from(blocks).where(eq(blocks.id, blockId)).get();
146
+ if (!block) return null;
147
+
148
+ // Get block definition for content schema and field order
149
+ let projectId: number | null = null;
150
+ if (block.pageId) {
151
+ const page = await db.select().from(pages).where(eq(pages.id, block.pageId)).get();
152
+ projectId = page?.projectId ?? null;
153
+ } else if (block.layoutId) {
154
+ const layout = await db.select().from(layouts).where(eq(layouts.id, block.layoutId)).get();
155
+ projectId = layout?.projectId ?? null;
156
+ }
157
+
158
+ const def = projectId
159
+ ? await db
160
+ .select()
161
+ .from(blockDefinitions)
162
+ .where(
163
+ and(eq(blockDefinitions.projectId, projectId), eq(blockDefinitions.blockId, block.type)),
164
+ )
165
+ .get()
166
+ : null;
167
+
168
+ const contentSchema = (def?.contentSchema as Record<string, any>) ?? null;
169
+ const fieldOrder = contentSchema?.properties
170
+ ? Object.keys(contentSchema.properties as Record<string, unknown>)
171
+ : undefined;
172
+
173
+ // Merge repeatable items into content
174
+ const items = sortByPosition(
175
+ await db.select().from(repeatableItems).where(eq(repeatableItems.blockId, blockId)),
176
+ );
177
+
178
+ nestChildItems(items);
179
+
180
+ const content = { ...(block.content as Record<string, unknown>) };
181
+ const topLevelFieldNames = new Set(
182
+ items.filter((item) => item.parentItemId === null).map((item) => item.fieldName),
183
+ );
184
+ for (const fieldName of topLevelFieldNames) {
185
+ content[fieldName] = items.filter((i) => i.fieldName === fieldName && i.parentItemId === null);
186
+ }
187
+
188
+ // Reorder keys to match field order from block definition
189
+ if (fieldOrder) {
190
+ const ordered: Record<string, unknown> = {};
191
+ for (const key of fieldOrder) {
192
+ if (key in content) ordered[key] = content[key];
193
+ }
194
+ for (const key of Object.keys(content)) {
195
+ if (!(key in ordered)) ordered[key] = content[key];
196
+ }
197
+ return { block, content: ordered, contentSchema };
198
+ }
199
+
200
+ return { block, content, contentSchema };
201
+ }
202
+
203
+ /**
204
+ * Generates and stores a summary for a block.
205
+ * Returns `{ pageId }` if the parent page has AI SEO enabled (caller should cascade).
206
+ */
207
+ export async function executeBlockSummary(
208
+ db: Database,
209
+ apiKey: string,
210
+ blockId: number,
211
+ ): Promise<{ pageId: number } | null> {
212
+ const assembled = await assembleBlockContent(db, blockId);
213
+ if (!assembled) return null;
214
+
215
+ const { block, content, contentSchema } = assembled;
216
+
217
+ const markdown =
218
+ contentSchema?.toMarkdown && contentSchema?.properties
219
+ ? contentToMarkdown(contentSchema.toMarkdown, contentSchema.properties, content)
220
+ : JSON.stringify(content);
221
+
222
+ const summary = await generateObjectSummary(apiKey, {
223
+ type: block.type,
224
+ markdown,
225
+ previousSummary: block.summary,
226
+ });
227
+
228
+ await db.update(blocks).set({ summary, updatedAt: Date.now() }).where(eq(blocks.id, blockId));
229
+
230
+ // Check if we should cascade to page SEO
231
+ if (summary !== block.summary && block.pageId) {
232
+ const page = await db.select().from(pages).where(eq(pages.id, block.pageId)).get();
233
+ if (page?.aiSeoEnabled !== false) {
234
+ return { pageId: block.pageId };
235
+ }
236
+ }
237
+
238
+ return null;
239
+ }
240
+
241
+ // --- Procedures ---
242
+
243
+ const repeatableItemSeedSchema = z.object({
244
+ tempId: z.string(),
245
+ parentTempId: z.string().nullable(),
246
+ fieldName: z.string(),
247
+ content: z.unknown(),
248
+ position: z.string(),
249
+ });
250
+
251
+ const createBlockSchema = z.object({
252
+ pageId: z.number(),
253
+ type: z.string(),
254
+ content: z.unknown(),
255
+ settings: z.unknown().optional(),
256
+ afterPosition: z.string().nullable().optional(),
257
+ repeatableItems: z.array(repeatableItemSeedSchema).optional(),
258
+ });
259
+
260
+ const getPageMarkdown = pub
261
+ .input(z.object({ pageId: z.number() }))
262
+ .handler(async ({ context, input }) => {
263
+ const { pageId } = input;
264
+
265
+ const page = await context.db.select().from(pages).where(eq(pages.id, pageId)).get();
266
+ if (!page) throw new ORPCError("NOT_FOUND");
267
+
268
+ // Get block definitions for content schemas and toMarkdown templates
269
+ const defs = await context.db
270
+ .select()
271
+ .from(blockDefinitions)
272
+ .where(eq(blockDefinitions.projectId, page.projectId));
273
+ const schemaByType = new Map<
274
+ string,
275
+ { title: string; properties: Record<string, any>; toMarkdown?: readonly string[] }
276
+ >();
277
+ for (const def of defs) {
278
+ const schema = def.contentSchema as Record<string, unknown> | null;
279
+ if (schema?.properties) {
280
+ schemaByType.set(def.blockId, {
281
+ title: def.title,
282
+ properties: schema.properties as Record<string, any>,
283
+ toMarkdown: schema.toMarkdown as readonly string[] | undefined,
284
+ });
285
+ }
286
+ }
287
+
288
+ // Get page blocks sorted by position
289
+ const pageBlocks = await context.db.select().from(blocks).where(eq(blocks.pageId, pageId));
290
+ const sorted = pageBlocks.sort((a, b) => comparePositions(a.position, b.position));
291
+
292
+ // Fetch all repeatable items for these blocks
293
+ const blockIds = sorted.map((b) => b.id);
294
+ const allItems =
295
+ blockIds.length > 0
296
+ ? sortByPosition(
297
+ await context.db
298
+ .select()
299
+ .from(repeatableItems)
300
+ .where(inArray(repeatableItems.blockId, blockIds)),
301
+ )
302
+ : [];
303
+ nestChildItems(allItems);
304
+ const itemsByBlock = new Map<number, typeof allItems>();
305
+ for (const item of allItems) {
306
+ if (item.parentItemId !== null) continue;
307
+ const list = itemsByBlock.get(item.blockId) ?? [];
308
+ list.push(item);
309
+ itemsByBlock.set(item.blockId, list);
310
+ }
311
+
312
+ // Also fetch layout blocks if page has a layout
313
+ let beforeMarkdown = "";
314
+ let afterMarkdown = "";
315
+ if (page.layoutId) {
316
+ const layoutBlocks = await context.db
317
+ .select()
318
+ .from(blocks)
319
+ .where(eq(blocks.layoutId, page.layoutId));
320
+ const sortedLayout = layoutBlocks.sort((a, b) => comparePositions(a.position, b.position));
321
+ const layoutBlockIds = sortedLayout.map((b) => b.id);
322
+ const layoutItems =
323
+ layoutBlockIds.length > 0
324
+ ? sortByPosition(
325
+ await context.db
326
+ .select()
327
+ .from(repeatableItems)
328
+ .where(inArray(repeatableItems.blockId, layoutBlockIds)),
329
+ )
330
+ : [];
331
+ nestChildItems(layoutItems);
332
+ const layoutItemsByBlock = new Map<number, typeof layoutItems>();
333
+ for (const item of layoutItems) {
334
+ if (item.parentItemId !== null) continue;
335
+ const list = layoutItemsByBlock.get(item.blockId) ?? [];
336
+ list.push(item);
337
+ layoutItemsByBlock.set(item.blockId, list);
338
+ }
339
+
340
+ const beforeParts: string[] = [];
341
+ const afterParts: string[] = [];
342
+ for (const block of sortedLayout) {
343
+ const schema = schemaByType.get(block.type);
344
+ if (!schema?.toMarkdown) continue;
345
+ const content = { ...(block.content as Record<string, unknown>) };
346
+ const items = layoutItemsByBlock.get(block.id) ?? [];
347
+ for (const fieldName of new Set(items.map((i) => i.fieldName))) {
348
+ content[fieldName] = items.filter((i) => i.fieldName === fieldName);
349
+ }
350
+ const md = `<!-- ${schema.title} -->\n${contentToMarkdown(schema.toMarkdown, schema.properties, content)}`;
351
+ if (block.placement === "before") beforeParts.push(md);
352
+ else afterParts.push(md);
353
+ }
354
+ beforeMarkdown = beforeParts.join("\n\n");
355
+ afterMarkdown = afterParts.join("\n\n");
356
+ }
357
+
358
+ // Convert page blocks to markdown
359
+ const pageParts = sorted.map((block) => {
360
+ const schema = schemaByType.get(block.type);
361
+ if (!schema?.toMarkdown) return JSON.stringify(block.content);
362
+ const content = { ...(block.content as Record<string, unknown>) };
363
+ const items = itemsByBlock.get(block.id) ?? [];
364
+ for (const fieldName of new Set(items.map((i) => i.fieldName))) {
365
+ content[fieldName] = items.filter((i) => i.fieldName === fieldName);
366
+ }
367
+ const md = contentToMarkdown(schema.toMarkdown, schema.properties, content);
368
+ return `<!-- ${schema.title} -->\n${md}`;
369
+ });
370
+
371
+ const parts = [beforeMarkdown, ...pageParts, afterMarkdown].filter(Boolean);
372
+ return { markdown: parts.join("\n\n") };
373
+ });
374
+
375
+ const getUsageCounts = pub
376
+ .input(z.object({ projectId: z.number() }))
377
+ .handler(async ({ context, input }) => {
378
+ const environment = await resolveEnvironment(
379
+ context.db,
380
+ input.projectId,
381
+ context.environmentName,
382
+ );
383
+ const result = await context.db
384
+ .select({
385
+ type: blocks.type,
386
+ count: sql<number>`count(*)`,
387
+ })
388
+ .from(blocks)
389
+ .leftJoin(pages, eq(blocks.pageId, pages.id))
390
+ .leftJoin(layouts, eq(blocks.layoutId, layouts.id))
391
+ .where(or(eq(pages.environmentId, environment.id), eq(layouts.environmentId, environment.id)))
392
+ .groupBy(blocks.type);
393
+ return result;
394
+ });
395
+
396
+ const create = authed.input(createBlockSchema).handler(async ({ context, input }) => {
397
+ const userId = context.user.id;
398
+ const { pageId, type, content, settings, afterPosition, repeatableItems: itemSeeds } = input;
399
+ const access = await assertPageAccess(context.db, pageId, userId);
400
+ if (!access) throw new ORPCError("NOT_FOUND");
401
+
402
+ const now = Date.now();
403
+
404
+ // Get all blocks for this page to determine correct position
405
+ const pageBlocks = sortByPosition(
406
+ await context.db.select().from(blocks).where(eq(blocks.pageId, pageId)),
407
+ );
408
+
409
+ let position: string;
410
+ if (afterPosition == null) {
411
+ // No afterPosition provided → insert at the end
412
+ const lastBlock = pageBlocks[pageBlocks.length - 1];
413
+ position = generateKeyBetween(lastBlock?.position ?? null, null);
414
+ } else if (afterPosition === "") {
415
+ // Empty string marker → insert at the beginning
416
+ const firstBlock = pageBlocks[0];
417
+ position = generateKeyBetween(null, firstBlock?.position ?? null);
418
+ } else {
419
+ // Insert after the specified position
420
+ const afterIndex = findLastIndexLe(pageBlocks, afterPosition!);
421
+ const nextBlock = afterIndex >= 0 ? pageBlocks[afterIndex + 1] : pageBlocks[0];
422
+ position = generateKeyBetween(
423
+ afterIndex >= 0 ? pageBlocks[afterIndex].position : null,
424
+ nextBlock?.position ?? null,
425
+ );
426
+ }
427
+ const result = await context.db
428
+ .insert(blocks)
429
+ .values({
430
+ pageId,
431
+ type,
432
+ content,
433
+ settings: settings ?? null,
434
+ position,
435
+ summary: "",
436
+ createdAt: now,
437
+ updatedAt: now,
438
+ })
439
+ .returning()
440
+ .get();
441
+
442
+ // Insert client-provided repeatable item seeds in topological order (parents before children)
443
+ if (itemSeeds && itemSeeds.length > 0) {
444
+ const tempIdToRealId = new Map<string, number>();
445
+
446
+ for (const seed of itemSeeds) {
447
+ const parentItemId = seed.parentTempId
448
+ ? (tempIdToRealId.get(seed.parentTempId) ?? null)
449
+ : null;
450
+ const inserted = await context.db
451
+ .insert(repeatableItems)
452
+ .values({
453
+ blockId: result.id,
454
+ parentItemId,
455
+ fieldName: seed.fieldName,
456
+ content: seed.content,
457
+ summary: "",
458
+ position: seed.position,
459
+ createdAt: now,
460
+ updatedAt: now,
461
+ })
462
+ .returning()
463
+ .get();
464
+ tempIdToRealId.set(seed.tempId, inserted.id);
465
+ }
466
+ }
467
+
468
+ scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
469
+ entityTable: "blocks",
470
+ entityId: result.id,
471
+ type: "summary",
472
+ delayMs: 0,
473
+ });
474
+ broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
475
+ queryKeys.pages.getByPath(access.page.fullPath),
476
+ queryKeys.blocks.getPageMarkdown(pageId),
477
+ queryKeys.blocks.getUsageCounts,
478
+ ]);
479
+
480
+ return result;
481
+ });
482
+
483
+ const updateContent = authed
484
+ .input(z.object({ id: z.number(), content: z.unknown() }))
485
+ .handler(async ({ context, input }) => {
486
+ const userId = context.user.id;
487
+ const { id, content } = input;
488
+ const access = await assertBlockAccess(context.db, id, userId);
489
+ if (!access) throw new ORPCError("NOT_FOUND");
490
+
491
+ // Merge partial content into existing content (frontend sends single-field patches)
492
+ const merged = {
493
+ ...(access.block.content as Record<string, unknown>),
494
+ ...(content as Record<string, unknown>),
495
+ };
496
+ const result = await context.db
497
+ .update(blocks)
498
+ .set({ content: merged, updatedAt: Date.now() })
499
+ .where(eq(blocks.id, id))
500
+ .returning()
501
+ .get();
502
+
503
+ scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
504
+ entityTable: "blocks",
505
+ entityId: id,
506
+ type: "summary",
507
+ delayMs: 5000,
508
+ });
509
+ // Granular invalidation: only refetch this block, not the entire page
510
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
511
+ queryKeys.blocks.get(id),
512
+ ...(access.block.pageId ? [queryKeys.blocks.getPageMarkdown(access.block.pageId)] : []),
513
+ ]);
514
+
515
+ return result;
516
+ });
517
+
518
+ const updateSettings = authed
519
+ .input(z.object({ id: z.number(), settings: z.unknown() }))
520
+ .handler(async ({ context, input }) => {
521
+ const userId = context.user.id;
522
+ const { id, settings } = input;
523
+ const access = await assertBlockAccess(context.db, id, userId);
524
+ if (!access) throw new ORPCError("NOT_FOUND");
525
+
526
+ const result = await context.db
527
+ .update(blocks)
528
+ .set({ settings, updatedAt: Date.now() })
529
+ .where(eq(blocks.id, id))
530
+ .returning()
531
+ .get();
532
+ // Granular invalidation: only refetch this block, not the entire page
533
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
534
+ queryKeys.blocks.get(id),
535
+ ...(access.block.pageId ? [queryKeys.blocks.getPageMarkdown(access.block.pageId)] : []),
536
+ ]);
537
+ return result;
538
+ });
539
+
540
+ const updatePosition = authed
541
+ .input(
542
+ z.object({
543
+ id: z.number(),
544
+ afterPosition: z.string().nullable().optional(),
545
+ beforePosition: z.string().nullable().optional(),
546
+ }),
547
+ )
548
+ .handler(async ({ context, input }) => {
549
+ const userId = context.user.id;
550
+ const { id, afterPosition, beforePosition } = input;
551
+ const access = await assertBlockAccess(context.db, id, userId);
552
+ if (!access) throw new ORPCError("NOT_FOUND");
553
+
554
+ // Query siblings (excluding the block being moved) to compute a correct position
555
+ const block = access.block;
556
+ const parentColumn = block.pageId ? blocks.pageId : blocks.layoutId;
557
+ const parentId = block.pageId ?? block.layoutId;
558
+ const siblings = parentId
559
+ ? sortByPosition(
560
+ (await context.db.select().from(blocks).where(eq(parentColumn, parentId))).filter(
561
+ (b) => b.id !== id,
562
+ ),
563
+ )
564
+ : [];
565
+
566
+ const after = afterPosition || null;
567
+ const before = beforePosition || null;
568
+
569
+ let position: string;
570
+ if (!after && !before) {
571
+ const last = siblings[siblings.length - 1];
572
+ position = generateKeyBetween(last?.position ?? null, null);
573
+ } else if (!after) {
574
+ const firstIdx = siblings.findIndex((b) => b.position >= before!);
575
+ position = generateKeyBetween(null, siblings[firstIdx]?.position ?? null);
576
+ } else {
577
+ const afterIdx = findLastIndexLe(siblings, after);
578
+ const nextPos = siblings[afterIdx + 1]?.position ?? null;
579
+ position = generateKeyBetween(siblings[afterIdx]?.position ?? null, nextPos);
580
+ }
581
+
582
+ const result = await context.db
583
+ .update(blocks)
584
+ .set({ position, updatedAt: Date.now() })
585
+ .where(eq(blocks.id, id))
586
+ .returning()
587
+ .get();
588
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
589
+ ...(access.pagePath
590
+ ? [queryKeys.pages.getByPath(access.pagePath)]
591
+ : [queryKeys.pages.getByPathAll]),
592
+ ...(access.block.pageId ? [queryKeys.blocks.getPageMarkdown(access.block.pageId)] : []),
593
+ queryKeys.blocks.getUsageCounts,
594
+ ]);
595
+ return result;
596
+ });
597
+
598
+ const deleteFn = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
599
+ const userId = context.user.id;
600
+ const { id } = input;
601
+ const access = await assertBlockAccess(context.db, id, userId);
602
+ if (!access) throw new ORPCError("NOT_FOUND");
603
+
604
+ const result = await context.db.delete(blocks).where(eq(blocks.id, id)).returning().get();
605
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
606
+ ...(access.pagePath
607
+ ? [queryKeys.pages.getByPath(access.pagePath)]
608
+ : [queryKeys.pages.getByPathAll]),
609
+ ...(access.block.pageId ? [queryKeys.blocks.getPageMarkdown(access.block.pageId)] : []),
610
+ queryKeys.blocks.getUsageCounts,
611
+ ]);
612
+ return result;
613
+ });
614
+
615
+ const deleteMany = authed
616
+ .input(z.object({ blockIds: z.array(z.number()) }))
617
+ .handler(async ({ context, input }) => {
618
+ const { blockIds } = input;
619
+ if (blockIds.length === 0) return [];
620
+
621
+ // Verify all blocks belong to an org the user is a member of
622
+ const authorizedBlocks = await context.db
623
+ .select({ id: blocks.id, projectId: projects.id })
624
+ .from(blocks)
625
+ .leftJoin(pages, eq(blocks.pageId, pages.id))
626
+ .leftJoin(layouts, eq(blocks.layoutId, layouts.id))
627
+ .innerJoin(projects, or(eq(projects.id, pages.projectId), eq(projects.id, layouts.projectId)))
628
+ .innerJoin(organizationTable, eq(organizationTable.slug, projects.organizationSlug))
629
+ .innerJoin(
630
+ member,
631
+ and(eq(member.organizationId, organizationTable.id), eq(member.userId, context.user.id)),
632
+ )
633
+ .where(inArray(blocks.id, blockIds));
634
+ if (authorizedBlocks.length !== blockIds.length) {
635
+ throw new ORPCError("NOT_FOUND");
636
+ }
637
+ const result = await context.db.delete(blocks).where(inArray(blocks.id, blockIds)).returning();
638
+ const projectId = authorizedBlocks[0]?.projectId;
639
+ if (projectId) {
640
+ broadcastInvalidation(context.env.ProjectRoom, projectId, [
641
+ queryKeys.pages.getByPathAll,
642
+ queryKeys.blocks.getUsageCounts,
643
+ ]);
644
+ }
645
+ return result;
646
+ });
647
+
648
+ const generateSummary = authed
649
+ .input(z.object({ id: z.number() }))
650
+ .handler(async ({ context, input }) => {
651
+ const userId = context.user.id;
652
+ const { id } = input;
653
+ const access = await assertBlockAccess(context.db, id, userId);
654
+ if (!access) throw new ORPCError("NOT_FOUND");
655
+
656
+ const seoStale = await executeBlockSummary(context.db, context.env.OPEN_ROUTER_API_KEY, id);
657
+ if (seoStale) {
658
+ scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
659
+ entityTable: "pages",
660
+ entityId: seoStale.pageId,
661
+ type: "seo",
662
+ delayMs: 15000,
663
+ });
664
+ }
665
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
666
+ queryKeys.blocks.get(id),
667
+ ...(access.pagePath
668
+ ? [queryKeys.pages.getByPath(access.pagePath)]
669
+ : [queryKeys.pages.getByPathAll]),
670
+ ...(access.block.pageId ? [queryKeys.blocks.getPageMarkdown(access.block.pageId)] : []),
671
+ queryKeys.blocks.getUsageCounts,
672
+ ]);
673
+ const updated = await context.db.select().from(blocks).where(eq(blocks.id, id)).get();
674
+ return updated;
675
+ });
676
+
677
+ const duplicate = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
678
+ const userId = context.user.id;
679
+ const { id } = input;
680
+ const access = await assertBlockAccess(context.db, id, userId);
681
+ if (!access) throw new ORPCError("NOT_FOUND");
682
+ const original = access.block;
683
+
684
+ const now = Date.now();
685
+
686
+ // Find the next block after the original to insert between them
687
+ const parentId = original.pageId ?? original.layoutId;
688
+ const parentColumn = original.pageId ? blocks.pageId : blocks.layoutId;
689
+ const siblings = parentId
690
+ ? sortByPosition(await context.db.select().from(blocks).where(eq(parentColumn, parentId)))
691
+ : [];
692
+ const originalIndex = siblings.findIndex((b) => b.id === id);
693
+ const nextBlock = originalIndex >= 0 ? siblings[originalIndex + 1] : undefined;
694
+ const position = generateKeyBetween(original.position, nextBlock?.position ?? null);
695
+
696
+ const result = await context.db
697
+ .insert(blocks)
698
+ .values({
699
+ pageId: original.pageId,
700
+ layoutId: original.layoutId,
701
+ type: original.type,
702
+ content: original.content,
703
+ settings: original.settings,
704
+ placement: original.placement,
705
+ summary: original.summary,
706
+ position,
707
+ createdAt: now,
708
+ updatedAt: now,
709
+ })
710
+ .returning()
711
+ .get();
712
+ broadcastInvalidation(context.env.ProjectRoom, access.projectId, [
713
+ ...(access.pagePath
714
+ ? [queryKeys.pages.getByPath(access.pagePath)]
715
+ : [queryKeys.pages.getByPathAll]),
716
+ ...(original.pageId ? [queryKeys.blocks.getPageMarkdown(original.pageId)] : []),
717
+ queryKeys.blocks.getUsageCounts,
718
+ ]);
719
+ return result;
720
+ });
721
+
722
+ const get = pub.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
723
+ const block = await context.db.select().from(blocks).where(eq(blocks.id, input.id)).get();
724
+ if (!block) throw new ORPCError("NOT_FOUND");
725
+
726
+ // Fetch repeatable items for this block
727
+ const items = await context.db
728
+ .select()
729
+ .from(repeatableItems)
730
+ .where(eq(repeatableItems.blockId, block.id));
731
+ const sorted = items.sort((a, b) => comparePositions(a.position, b.position));
732
+
733
+ // Build a map of parentItemId → grouped children by fieldName
734
+ const childrenByParent = new Map<number | null, Map<string, typeof sorted>>();
735
+ for (const item of sorted) {
736
+ let fieldMap = childrenByParent.get(item.parentItemId);
737
+ if (!fieldMap) {
738
+ fieldMap = new Map();
739
+ childrenByParent.set(item.parentItemId, fieldMap);
740
+ }
741
+ const list = fieldMap.get(item.fieldName) ?? [];
742
+ list.push(item);
743
+ fieldMap.set(item.fieldName, list);
744
+ }
745
+
746
+ // Add _itemId markers to block content for top-level items
747
+ const content = { ...(block.content as Record<string, unknown>) };
748
+ const topLevelFields = childrenByParent.get(null);
749
+ if (topLevelFields) {
750
+ for (const [fieldName, fieldItems] of topLevelFields) {
751
+ content[fieldName] = fieldItems.map((i) => ({ _itemId: i.id }));
752
+ }
753
+ }
754
+
755
+ // Add _itemId markers to each item's content for its nested children
756
+ for (const item of sorted) {
757
+ const nestedFields = childrenByParent.get(item.id);
758
+ if (!nestedFields) continue;
759
+ const itemContent = { ...(item.content as Record<string, unknown>) };
760
+ for (const [fieldName, fieldItems] of nestedFields) {
761
+ itemContent[fieldName] = fieldItems.map((i) => ({ _itemId: i.id }));
762
+ }
763
+ (item as any).content = itemContent;
764
+ }
765
+
766
+ // Collect and fetch referenced files
767
+ const fileIds = new Set<number>();
768
+ collectFileIds(content, fileIds);
769
+ for (const item of sorted) {
770
+ collectFileIds(item.content as Record<string, unknown>, fileIds);
771
+ }
772
+
773
+ const fileRows =
774
+ fileIds.size > 0
775
+ ? await context.db
776
+ .select()
777
+ .from(files)
778
+ .where(inArray(files.id, [...fileIds]))
779
+ : [];
780
+
781
+ return {
782
+ block: { ...block, content },
783
+ repeatableItems: sorted,
784
+ files: fileRows,
785
+ };
786
+ });
787
+
788
+ export const blockProcedures = {
789
+ get,
790
+ getPageMarkdown,
791
+ getUsageCounts,
792
+ create,
793
+ updateContent,
794
+ updateSettings,
795
+ updatePosition,
796
+ delete: deleteFn,
797
+ deleteMany,
798
+ generateSummary,
799
+ duplicate,
800
+ };