@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,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
|
+
};
|