@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,818 @@
|
|
|
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 { assertPageAccess, getAuthorizedProject } 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
|
+
pages,
|
|
23
|
+
projects,
|
|
24
|
+
repeatableItems,
|
|
25
|
+
} from "../schema";
|
|
26
|
+
|
|
27
|
+
// --- AI Executors ---
|
|
28
|
+
|
|
29
|
+
const SEO_STRIP_KEYS = new Set([
|
|
30
|
+
"createdAt",
|
|
31
|
+
"updatedAt",
|
|
32
|
+
"position",
|
|
33
|
+
"settings",
|
|
34
|
+
"pageId",
|
|
35
|
+
"blockId",
|
|
36
|
+
"fieldName",
|
|
37
|
+
"summary",
|
|
38
|
+
"_fileId",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function stripNonSeoFields(obj: Record<string, unknown>): Record<string, unknown> {
|
|
42
|
+
const result: Record<string, unknown> = {};
|
|
43
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
44
|
+
if (SEO_STRIP_KEYS.has(key)) continue;
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
result[key] = value.map((item) =>
|
|
47
|
+
item && typeof item === "object" && !Array.isArray(item)
|
|
48
|
+
? stripNonSeoFields(item as Record<string, unknown>)
|
|
49
|
+
: item,
|
|
50
|
+
);
|
|
51
|
+
} else if (value && typeof value === "object") {
|
|
52
|
+
result[key] = stripNonSeoFields(value as Record<string, unknown>);
|
|
53
|
+
} else {
|
|
54
|
+
result[key] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function generatePageSeoFromAi(
|
|
61
|
+
apiKey: string,
|
|
62
|
+
options: {
|
|
63
|
+
fullPath: string;
|
|
64
|
+
blocks: { type: string; markdown: string }[];
|
|
65
|
+
previousMetaTitle?: string | null;
|
|
66
|
+
previousMetaDescription?: string | null;
|
|
67
|
+
},
|
|
68
|
+
) {
|
|
69
|
+
const stabilityBlock =
|
|
70
|
+
options.previousMetaTitle || options.previousMetaDescription
|
|
71
|
+
? outdent`
|
|
72
|
+
|
|
73
|
+
<previous_metadata>
|
|
74
|
+
<metaTitle>${options.previousMetaTitle ?? ""}</metaTitle>
|
|
75
|
+
<metaDescription>${options.previousMetaDescription ?? ""}</metaDescription>
|
|
76
|
+
</previous_metadata>
|
|
77
|
+
<stability_instruction>
|
|
78
|
+
Metadata was previously generated for this page.
|
|
79
|
+
Return the SAME metadata unless it is no longer accurate.
|
|
80
|
+
Only change it if the page content has meaningfully changed.
|
|
81
|
+
</stability_instruction>
|
|
82
|
+
`
|
|
83
|
+
: "";
|
|
84
|
+
|
|
85
|
+
return await chat({
|
|
86
|
+
adapter: createOpenRouterText("google/gemini-3-flash-preview", apiKey),
|
|
87
|
+
outputSchema: z.object({
|
|
88
|
+
metaTitle: z.string(),
|
|
89
|
+
metaDescription: z.string(),
|
|
90
|
+
}),
|
|
91
|
+
messages: [
|
|
92
|
+
{
|
|
93
|
+
role: "user",
|
|
94
|
+
content: outdent`
|
|
95
|
+
<instruction>
|
|
96
|
+
Generate SEO metadata for a web page.
|
|
97
|
+
</instruction>
|
|
98
|
+
|
|
99
|
+
<constraints>
|
|
100
|
+
- metaTitle: under 60 characters, concise and descriptive. Use sentence case (only capitalize the first word and proper nouns). Do NOT include the site/brand name — it will be appended automatically. Do NOT use separators like "-", "|", or ":" to split the title into parts.
|
|
101
|
+
- metaDescription: under 160 characters, compelling summary of the page
|
|
102
|
+
- Be specific to the actual content, not generic
|
|
103
|
+
- Don't use markdown, just plain text
|
|
104
|
+
</constraints>
|
|
105
|
+
|
|
106
|
+
<page>
|
|
107
|
+
<path>${options.fullPath}</path>
|
|
108
|
+
<blocks>${JSON.stringify(options.blocks)}</blocks>
|
|
109
|
+
</page>
|
|
110
|
+
${stabilityBlock}
|
|
111
|
+
`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function generatePageDraftFromAi(
|
|
118
|
+
apiKey: string,
|
|
119
|
+
options: {
|
|
120
|
+
contentDescription: string;
|
|
121
|
+
blockDefs: {
|
|
122
|
+
blockId: string;
|
|
123
|
+
title: string;
|
|
124
|
+
description: string;
|
|
125
|
+
contentSchema: unknown;
|
|
126
|
+
settingsSchema?: unknown;
|
|
127
|
+
}[];
|
|
128
|
+
},
|
|
129
|
+
) {
|
|
130
|
+
const blockDefsForPrompt = options.blockDefs.map((def) => ({
|
|
131
|
+
blockId: def.blockId,
|
|
132
|
+
title: def.title,
|
|
133
|
+
description: def.description,
|
|
134
|
+
contentSchema: def.contentSchema,
|
|
135
|
+
...(def.settingsSchema ? { settingsSchema: def.settingsSchema } : {}),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
const text = await chat({
|
|
139
|
+
adapter: createOpenRouterText("google/gemini-3-flash-preview", apiKey),
|
|
140
|
+
stream: false,
|
|
141
|
+
messages: [
|
|
142
|
+
{
|
|
143
|
+
role: "user",
|
|
144
|
+
content: outdent`
|
|
145
|
+
<instruction>
|
|
146
|
+
Generate a page layout with blocks based on the user's description.
|
|
147
|
+
</instruction>
|
|
148
|
+
|
|
149
|
+
<available_blocks>
|
|
150
|
+
${JSON.stringify(blockDefsForPrompt)}
|
|
151
|
+
</available_blocks>
|
|
152
|
+
|
|
153
|
+
<page_description>
|
|
154
|
+
${options.contentDescription}
|
|
155
|
+
</page_description>
|
|
156
|
+
|
|
157
|
+
<output_format>
|
|
158
|
+
Return a JSON array of blocks. Each block must have:
|
|
159
|
+
- "type": the blockId from available_blocks
|
|
160
|
+
- "content": an object matching the contentSchema for that block type
|
|
161
|
+
- "settings" (optional): an object matching the settingsSchema for that block type, if it has one
|
|
162
|
+
|
|
163
|
+
Only use blocks from available_blocks. Ensure content matches schema constraints (maxLength, etc.).
|
|
164
|
+
For RepeatableItems fields (arrays), provide an array of objects matching the nested schema.
|
|
165
|
+
For settings, pick values from the enum options or boolean values defined in the settingsSchema.
|
|
166
|
+
For String fields, you may use markdown formatting: **bold** and *italic*.
|
|
167
|
+
|
|
168
|
+
IMPORTANT: Return ONLY the raw JSON array. Do NOT wrap it in markdown code fences or any other formatting. The response must be valid JSON that can be parsed directly.
|
|
169
|
+
</output_format>
|
|
170
|
+
`,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return JSON.parse(text) as {
|
|
176
|
+
type: string;
|
|
177
|
+
content: Record<string, unknown>;
|
|
178
|
+
settings?: Record<string, unknown>;
|
|
179
|
+
}[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function executePageSeo(db: Database, apiKey: string, pageId: number) {
|
|
183
|
+
const page = await db.select().from(pages).where(eq(pages.id, pageId)).get();
|
|
184
|
+
if (!page || page.aiSeoEnabled === false) return;
|
|
185
|
+
|
|
186
|
+
// Get all blocks for this page
|
|
187
|
+
const pageBlocks = await db.select().from(blocks).where(eq(blocks.pageId, pageId));
|
|
188
|
+
const sorted = pageBlocks.sort((a, b) => comparePositions(a.position, b.position));
|
|
189
|
+
|
|
190
|
+
// Get block definitions for content schemas
|
|
191
|
+
const defs = await db
|
|
192
|
+
.select()
|
|
193
|
+
.from(blockDefinitions)
|
|
194
|
+
.where(eq(blockDefinitions.projectId, page.projectId));
|
|
195
|
+
const contentSchemaByType = new Map<string, any>();
|
|
196
|
+
const fieldOrderByType = new Map<string, string[]>();
|
|
197
|
+
for (const def of defs) {
|
|
198
|
+
const schema = def.contentSchema as Record<string, unknown> | null;
|
|
199
|
+
if (schema?.properties) {
|
|
200
|
+
contentSchemaByType.set(def.blockId, schema);
|
|
201
|
+
fieldOrderByType.set(def.blockId, Object.keys(schema.properties as Record<string, unknown>));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Assemble content for each block (merge repeatable items)
|
|
206
|
+
const blockIds = sorted.map((b) => b.id);
|
|
207
|
+
const allItems =
|
|
208
|
+
blockIds.length > 0
|
|
209
|
+
? sortByPosition(
|
|
210
|
+
await db.select().from(repeatableItems).where(inArray(repeatableItems.blockId, blockIds)),
|
|
211
|
+
)
|
|
212
|
+
: [];
|
|
213
|
+
|
|
214
|
+
nestChildItems(allItems);
|
|
215
|
+
|
|
216
|
+
const itemsByBlock = new Map<number, typeof allItems>();
|
|
217
|
+
for (const item of allItems) {
|
|
218
|
+
if (item.parentItemId !== null) continue;
|
|
219
|
+
const list = itemsByBlock.get(item.blockId) ?? [];
|
|
220
|
+
list.push(item);
|
|
221
|
+
itemsByBlock.set(item.blockId, list);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const markdownBlocks = sorted.map((block) => {
|
|
225
|
+
const content = { ...(block.content as Record<string, unknown>) };
|
|
226
|
+
const items = itemsByBlock.get(block.id) ?? [];
|
|
227
|
+
const fieldNames = new Set(items.map((i) => i.fieldName));
|
|
228
|
+
for (const fieldName of fieldNames) {
|
|
229
|
+
content[fieldName] = items.filter((i) => i.fieldName === fieldName);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const stripped = stripNonSeoFields(content);
|
|
233
|
+
const schema = contentSchemaByType.get(block.type);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
type: block.type,
|
|
237
|
+
markdown:
|
|
238
|
+
schema?.toMarkdown && schema?.properties
|
|
239
|
+
? contentToMarkdown(schema.toMarkdown, schema.properties, stripped)
|
|
240
|
+
: JSON.stringify(stripped),
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const seo = await generatePageSeoFromAi(apiKey, {
|
|
245
|
+
fullPath: page.fullPath,
|
|
246
|
+
blocks: markdownBlocks,
|
|
247
|
+
previousMetaTitle: page.metaTitle,
|
|
248
|
+
previousMetaDescription: page.metaDescription,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await db
|
|
252
|
+
.update(pages)
|
|
253
|
+
.set({
|
|
254
|
+
metaTitle: seo.metaTitle,
|
|
255
|
+
metaDescription: seo.metaDescription,
|
|
256
|
+
updatedAt: Date.now(),
|
|
257
|
+
})
|
|
258
|
+
.where(eq(pages.id, pageId));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Content Assembly Helpers ---
|
|
262
|
+
|
|
263
|
+
function comparePositions(a: string, b: string): number {
|
|
264
|
+
if (a < b) return -1;
|
|
265
|
+
if (a > b) return 1;
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sortByPosition<T extends { position: string }>(items: T[]): T[] {
|
|
270
|
+
return items.sort((a, b) => comparePositions(a.position, b.position));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function collectFileIds(content: Record<string, unknown>, fileIds: Set<number>) {
|
|
274
|
+
for (const value of Object.values(content)) {
|
|
275
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
276
|
+
const obj = value as Record<string, unknown>;
|
|
277
|
+
if ("_fileId" in obj && obj._fileId != null) {
|
|
278
|
+
fileIds.add(Number(obj._fileId));
|
|
279
|
+
} else {
|
|
280
|
+
collectFileIds(obj, fileIds);
|
|
281
|
+
}
|
|
282
|
+
} else if (Array.isArray(value)) {
|
|
283
|
+
for (const item of value) {
|
|
284
|
+
if (item && typeof item === "object" && "content" in item) {
|
|
285
|
+
collectFileIds((item as { content: Record<string, unknown> }).content, fileIds);
|
|
286
|
+
} else if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
287
|
+
collectFileIds(item as Record<string, unknown>, fileIds);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Recursively nest child items into their parent item's content. */
|
|
295
|
+
function nestChildItems(
|
|
296
|
+
allItems: { id: number; parentItemId: number | null; fieldName: string; content: unknown }[],
|
|
297
|
+
) {
|
|
298
|
+
const childrenByParent = new Map<number, Map<string, typeof allItems>>();
|
|
299
|
+
for (const item of allItems) {
|
|
300
|
+
if (item.parentItemId === null) continue;
|
|
301
|
+
let fieldMap = childrenByParent.get(item.parentItemId);
|
|
302
|
+
if (!fieldMap) {
|
|
303
|
+
fieldMap = new Map();
|
|
304
|
+
childrenByParent.set(item.parentItemId, fieldMap);
|
|
305
|
+
}
|
|
306
|
+
const list = fieldMap.get(item.fieldName) ?? [];
|
|
307
|
+
list.push(item);
|
|
308
|
+
fieldMap.set(item.fieldName, list);
|
|
309
|
+
}
|
|
310
|
+
for (const item of allItems) {
|
|
311
|
+
const childFields = childrenByParent.get(item.id);
|
|
312
|
+
if (!childFields) continue;
|
|
313
|
+
const content = item.content as Record<string, unknown>;
|
|
314
|
+
for (const [fieldName, children] of childFields) {
|
|
315
|
+
content[fieldName] = children;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function buildFileMap(db: Database, fileIds: Set<number>) {
|
|
321
|
+
if (fileIds.size === 0) return new Map();
|
|
322
|
+
const rows = await db
|
|
323
|
+
.select()
|
|
324
|
+
.from(files)
|
|
325
|
+
.where(inArray(files.id, [...fileIds]));
|
|
326
|
+
return new Map(rows.map((f) => [f.id, f]));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --- Procedures ---
|
|
330
|
+
|
|
331
|
+
const updatePageSchema = z.object({
|
|
332
|
+
pathSegment: z.string(),
|
|
333
|
+
parentPageId: z.number().nullable().optional(),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const DEFAULT_HERO_BLOCK = {
|
|
337
|
+
type: "hero",
|
|
338
|
+
content: {
|
|
339
|
+
title: "A page title",
|
|
340
|
+
description: "An engaging block description",
|
|
341
|
+
cta: { type: "external", text: "Get started", href: "/", newTab: false },
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const createPageSchema = z.object({
|
|
346
|
+
projectId: z.number(),
|
|
347
|
+
pathSegment: z.string(),
|
|
348
|
+
parentPageId: z.number().optional(),
|
|
349
|
+
layoutId: z.number(),
|
|
350
|
+
contentDescription: z.string().optional(),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Public procedures
|
|
354
|
+
|
|
355
|
+
const getByPath = pub
|
|
356
|
+
.input(z.object({ path: z.string(), projectSlug: z.string() }))
|
|
357
|
+
.handler(async ({ context, input }) => {
|
|
358
|
+
const { path: fullPath, projectSlug } = input;
|
|
359
|
+
const db = context.db;
|
|
360
|
+
|
|
361
|
+
const project = await db.select().from(projects).where(eq(projects.slug, projectSlug)).get();
|
|
362
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
363
|
+
|
|
364
|
+
const environment = await resolveEnvironment(db, project.id, context.environmentName);
|
|
365
|
+
|
|
366
|
+
const page = await db
|
|
367
|
+
.select()
|
|
368
|
+
.from(pages)
|
|
369
|
+
.where(and(eq(pages.fullPath, fullPath), eq(pages.environmentId, environment.id)))
|
|
370
|
+
.get();
|
|
371
|
+
if (!page) throw new ORPCError("NOT_FOUND");
|
|
372
|
+
|
|
373
|
+
// Fetch page blocks sorted by position
|
|
374
|
+
const pageBlocks = sortByPosition(
|
|
375
|
+
await db.select().from(blocks).where(eq(blocks.pageId, page.id)),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// Fetch layout and its blocks
|
|
379
|
+
const layout = page.layoutId
|
|
380
|
+
? await db.select().from(layouts).where(eq(layouts.id, page.layoutId)).get()
|
|
381
|
+
: null;
|
|
382
|
+
|
|
383
|
+
const layoutBlocks = layout
|
|
384
|
+
? sortByPosition(await db.select().from(blocks).where(eq(blocks.layoutId, layout.id)))
|
|
385
|
+
: [];
|
|
386
|
+
|
|
387
|
+
// Merge all blocks into a single array
|
|
388
|
+
const allBlocks = [...pageBlocks, ...layoutBlocks];
|
|
389
|
+
const allBlockIds = allBlocks.map((b) => b.id);
|
|
390
|
+
|
|
391
|
+
// Fetch all repeatable items for all blocks (top-level + nested)
|
|
392
|
+
const allItems =
|
|
393
|
+
allBlockIds.length > 0
|
|
394
|
+
? sortByPosition(
|
|
395
|
+
await db
|
|
396
|
+
.select()
|
|
397
|
+
.from(repeatableItems)
|
|
398
|
+
.where(inArray(repeatableItems.blockId, allBlockIds)),
|
|
399
|
+
)
|
|
400
|
+
: [];
|
|
401
|
+
|
|
402
|
+
// Group top-level items by block:fieldName for _itemId markers
|
|
403
|
+
const topLevelItemsByBlockField = new Map<string, typeof allItems>();
|
|
404
|
+
for (const item of allItems) {
|
|
405
|
+
if (item.parentItemId !== null) continue;
|
|
406
|
+
const key = `${item.blockId}:${item.fieldName}`;
|
|
407
|
+
const list = topLevelItemsByBlockField.get(key) ?? [];
|
|
408
|
+
list.push(item);
|
|
409
|
+
topLevelItemsByBlockField.set(key, list);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Add _itemId markers to block content for repeatable fields
|
|
413
|
+
const blocksWithMarkers = allBlocks.map((block) => {
|
|
414
|
+
const content = { ...(block.content as Record<string, unknown>) };
|
|
415
|
+
for (const [key, items] of topLevelItemsByBlockField) {
|
|
416
|
+
if (!key.startsWith(`${block.id}:`)) continue;
|
|
417
|
+
const fieldName = key.slice(String(block.id).length + 1);
|
|
418
|
+
content[fieldName] = items.map((item) => ({ _itemId: item.id }));
|
|
419
|
+
}
|
|
420
|
+
return { ...block, content };
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Collect file IDs from all block content and repeatable item content
|
|
424
|
+
const fileIds = new Set<number>();
|
|
425
|
+
for (const block of blocksWithMarkers) {
|
|
426
|
+
collectFileIds(block.content as Record<string, unknown>, fileIds);
|
|
427
|
+
}
|
|
428
|
+
for (const item of allItems) {
|
|
429
|
+
collectFileIds(item.content as Record<string, unknown>, fileIds);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Fetch referenced files
|
|
433
|
+
const fileRows = await buildFileMap(db, fileIds);
|
|
434
|
+
|
|
435
|
+
// Build ID arrays
|
|
436
|
+
const blockIds = pageBlocks.map((b) => b.id);
|
|
437
|
+
const beforeBlockIds = layoutBlocks.filter((b) => b.placement === "before").map((b) => b.id);
|
|
438
|
+
const afterBlockIds = layoutBlocks.filter((b) => b.placement === "after").map((b) => b.id);
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
page: { ...page, blockIds },
|
|
442
|
+
projectName: project.name,
|
|
443
|
+
layout: layout
|
|
444
|
+
? { id: layout.id, layoutId: layout.layoutId, beforeBlockIds, afterBlockIds }
|
|
445
|
+
: null,
|
|
446
|
+
blocks: blocksWithMarkers,
|
|
447
|
+
repeatableItems: allItems,
|
|
448
|
+
files: [...fileRows.values()],
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Lightweight version of getByPath — returns only structural data (page, layout,
|
|
454
|
+
* project name, block ID arrays). No blocks, items, or files.
|
|
455
|
+
* Used by the frontend for client-side refetches after structural mutations.
|
|
456
|
+
*/
|
|
457
|
+
const getStructure = pub
|
|
458
|
+
.input(z.object({ path: z.string(), projectSlug: z.string() }))
|
|
459
|
+
.handler(async ({ context, input }) => {
|
|
460
|
+
const { path: fullPath, projectSlug } = input;
|
|
461
|
+
const db = context.db;
|
|
462
|
+
|
|
463
|
+
const project = await db.select().from(projects).where(eq(projects.slug, projectSlug)).get();
|
|
464
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
465
|
+
|
|
466
|
+
const environment = await resolveEnvironment(db, project.id, context.environmentName);
|
|
467
|
+
|
|
468
|
+
const page = await db
|
|
469
|
+
.select()
|
|
470
|
+
.from(pages)
|
|
471
|
+
.where(and(eq(pages.fullPath, fullPath), eq(pages.environmentId, environment.id)))
|
|
472
|
+
.get();
|
|
473
|
+
if (!page) throw new ORPCError("NOT_FOUND");
|
|
474
|
+
|
|
475
|
+
// Only fetch block IDs and positions (no content, items, or files)
|
|
476
|
+
const pageBlocks = sortByPosition(
|
|
477
|
+
await db
|
|
478
|
+
.select({ id: blocks.id, position: blocks.position })
|
|
479
|
+
.from(blocks)
|
|
480
|
+
.where(eq(blocks.pageId, page.id)),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const layout = page.layoutId
|
|
484
|
+
? await db.select().from(layouts).where(eq(layouts.id, page.layoutId)).get()
|
|
485
|
+
: null;
|
|
486
|
+
|
|
487
|
+
const layoutBlocks = layout
|
|
488
|
+
? sortByPosition(
|
|
489
|
+
await db
|
|
490
|
+
.select({ id: blocks.id, position: blocks.position, placement: blocks.placement })
|
|
491
|
+
.from(blocks)
|
|
492
|
+
.where(eq(blocks.layoutId, layout.id)),
|
|
493
|
+
)
|
|
494
|
+
: [];
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
page: { ...page, blockIds: pageBlocks.map((b) => b.id) },
|
|
498
|
+
projectName: project.name,
|
|
499
|
+
layout: layout
|
|
500
|
+
? {
|
|
501
|
+
id: layout.id,
|
|
502
|
+
layoutId: layout.layoutId,
|
|
503
|
+
beforeBlockIds: layoutBlocks.filter((b) => b.placement === "before").map((b) => b.id),
|
|
504
|
+
afterBlockIds: layoutBlocks.filter((b) => b.placement === "after").map((b) => b.id),
|
|
505
|
+
}
|
|
506
|
+
: null,
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const list = pub.input(z.object({ projectId: z.number() })).handler(async ({ context, input }) => {
|
|
511
|
+
const environment = await resolveEnvironment(
|
|
512
|
+
context.db,
|
|
513
|
+
input.projectId,
|
|
514
|
+
context.environmentName,
|
|
515
|
+
);
|
|
516
|
+
return await context.db
|
|
517
|
+
.select()
|
|
518
|
+
.from(pages)
|
|
519
|
+
.where(and(eq(pages.projectId, input.projectId), eq(pages.environmentId, environment.id)));
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const get = pub.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
523
|
+
const { id } = input;
|
|
524
|
+
const result = await context.db.select().from(pages).where(eq(pages.id, id)).get();
|
|
525
|
+
if (!result) throw new ORPCError("NOT_FOUND");
|
|
526
|
+
return result;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Protected procedures
|
|
530
|
+
|
|
531
|
+
const create = authed.input(createPageSchema).handler(async ({ context, input }) => {
|
|
532
|
+
const userId = context.user.id;
|
|
533
|
+
const { projectId, pathSegment, parentPageId, layoutId, contentDescription } = input;
|
|
534
|
+
const project = await getAuthorizedProject(context.db, projectId, userId);
|
|
535
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
536
|
+
const environment = await resolveEnvironment(context.db, projectId, context.environmentName);
|
|
537
|
+
|
|
538
|
+
let generatedBlocks: {
|
|
539
|
+
type: string;
|
|
540
|
+
content: Record<string, unknown>;
|
|
541
|
+
settings?: Record<string, unknown>;
|
|
542
|
+
}[] = [DEFAULT_HERO_BLOCK];
|
|
543
|
+
|
|
544
|
+
if (contentDescription) {
|
|
545
|
+
try {
|
|
546
|
+
const allDefs = await context.db
|
|
547
|
+
.select()
|
|
548
|
+
.from(blockDefinitions)
|
|
549
|
+
.where(eq(blockDefinitions.projectId, projectId));
|
|
550
|
+
const defs = allDefs.filter((d) => !d.layoutOnly);
|
|
551
|
+
|
|
552
|
+
if (defs.length > 0) {
|
|
553
|
+
generatedBlocks = await generatePageDraftFromAi(context.env.OPEN_ROUTER_API_KEY, {
|
|
554
|
+
contentDescription,
|
|
555
|
+
blockDefs: defs.map((d) => ({
|
|
556
|
+
blockId: d.blockId,
|
|
557
|
+
title: d.title,
|
|
558
|
+
description: d.description ?? "",
|
|
559
|
+
contentSchema: d.contentSchema,
|
|
560
|
+
settingsSchema: d.settingsSchema ?? undefined,
|
|
561
|
+
})),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
} catch (error) {
|
|
565
|
+
console.error("AI generation failed, using default block:", error);
|
|
566
|
+
generatedBlocks = [DEFAULT_HERO_BLOCK];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Compute full path
|
|
571
|
+
let fullPath = `/${pathSegment}`;
|
|
572
|
+
if (parentPageId) {
|
|
573
|
+
const parent = await context.db.select().from(pages).where(eq(pages.id, parentPageId)).get();
|
|
574
|
+
if (parent) {
|
|
575
|
+
fullPath = `${parent.fullPath}/${pathSegment}`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const now = Date.now();
|
|
580
|
+
const page = await context.db
|
|
581
|
+
.insert(pages)
|
|
582
|
+
.values({
|
|
583
|
+
projectId,
|
|
584
|
+
environmentId: environment.id,
|
|
585
|
+
pathSegment,
|
|
586
|
+
fullPath,
|
|
587
|
+
parentPageId: parentPageId ?? null,
|
|
588
|
+
layoutId,
|
|
589
|
+
createdAt: now,
|
|
590
|
+
updatedAt: now,
|
|
591
|
+
})
|
|
592
|
+
.returning()
|
|
593
|
+
.get();
|
|
594
|
+
|
|
595
|
+
// Create blocks
|
|
596
|
+
let prevPosition: string | null = null;
|
|
597
|
+
for (const genBlock of generatedBlocks) {
|
|
598
|
+
const position = generateKeyBetween(prevPosition, null);
|
|
599
|
+
prevPosition = position;
|
|
600
|
+
|
|
601
|
+
// Separate scalar content from array fields (repeatable items)
|
|
602
|
+
const scalarContent: Record<string, unknown> = {};
|
|
603
|
+
const arrayFields: Record<string, unknown[]> = {};
|
|
604
|
+
for (const [key, value] of Object.entries(genBlock.content)) {
|
|
605
|
+
if (Array.isArray(value)) {
|
|
606
|
+
arrayFields[key] = value;
|
|
607
|
+
} else {
|
|
608
|
+
scalarContent[key] = value;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const block = await context.db
|
|
613
|
+
.insert(blocks)
|
|
614
|
+
.values({
|
|
615
|
+
pageId: page.id,
|
|
616
|
+
type: genBlock.type,
|
|
617
|
+
content: scalarContent,
|
|
618
|
+
settings: genBlock.settings ?? null,
|
|
619
|
+
summary: "",
|
|
620
|
+
position,
|
|
621
|
+
createdAt: now,
|
|
622
|
+
updatedAt: now,
|
|
623
|
+
})
|
|
624
|
+
.returning()
|
|
625
|
+
.get();
|
|
626
|
+
|
|
627
|
+
// Create repeatable items for array fields
|
|
628
|
+
for (const [fieldName, items] of Object.entries(arrayFields)) {
|
|
629
|
+
let itemPrevPos: string | null = null;
|
|
630
|
+
for (const itemContent of items) {
|
|
631
|
+
const itemPos = generateKeyBetween(itemPrevPos, null);
|
|
632
|
+
itemPrevPos = itemPos;
|
|
633
|
+
await context.db.insert(repeatableItems).values({
|
|
634
|
+
blockId: block.id,
|
|
635
|
+
fieldName,
|
|
636
|
+
content: itemContent,
|
|
637
|
+
summary: "",
|
|
638
|
+
position: itemPos,
|
|
639
|
+
createdAt: now,
|
|
640
|
+
updatedAt: now,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
|
|
646
|
+
entityTable: "blocks",
|
|
647
|
+
entityId: block.id,
|
|
648
|
+
type: "summary",
|
|
649
|
+
delayMs: 0,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
broadcastInvalidation(context.env.ProjectRoom, projectId, [
|
|
654
|
+
queryKeys.pages.list,
|
|
655
|
+
queryKeys.pages.getById(page.id),
|
|
656
|
+
]);
|
|
657
|
+
|
|
658
|
+
return { page, fullPath: page.fullPath };
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const update = authed
|
|
662
|
+
.input(updatePageSchema.extend({ id: z.number() }))
|
|
663
|
+
.handler(async ({ context, input }) => {
|
|
664
|
+
const userId = context.user.id;
|
|
665
|
+
const { id, ...body } = input;
|
|
666
|
+
const access = await assertPageAccess(context.db, id, userId);
|
|
667
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
668
|
+
|
|
669
|
+
const result = await context.db
|
|
670
|
+
.update(pages)
|
|
671
|
+
.set({ ...body, updatedAt: Date.now() })
|
|
672
|
+
.where(eq(pages.id, id))
|
|
673
|
+
.returning()
|
|
674
|
+
.get();
|
|
675
|
+
broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
|
|
676
|
+
queryKeys.pages.list,
|
|
677
|
+
queryKeys.pages.getById(id),
|
|
678
|
+
]);
|
|
679
|
+
return result;
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const deleteFn = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
683
|
+
const userId = context.user.id;
|
|
684
|
+
const { id } = input;
|
|
685
|
+
const access = await assertPageAccess(context.db, id, userId);
|
|
686
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
687
|
+
|
|
688
|
+
const result = await context.db.delete(pages).where(eq(pages.id, id)).returning().get();
|
|
689
|
+
broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
|
|
690
|
+
queryKeys.pages.list,
|
|
691
|
+
queryKeys.pages.getById(id),
|
|
692
|
+
]);
|
|
693
|
+
return result;
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const setAiSeo = authed
|
|
697
|
+
.input(z.object({ id: z.number(), enabled: z.boolean() }))
|
|
698
|
+
.handler(async ({ context, input }) => {
|
|
699
|
+
const userId = context.user.id;
|
|
700
|
+
const { id, enabled } = input;
|
|
701
|
+
const access = await assertPageAccess(context.db, id, userId);
|
|
702
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
703
|
+
|
|
704
|
+
const result = await context.db
|
|
705
|
+
.update(pages)
|
|
706
|
+
.set({ aiSeoEnabled: enabled, updatedAt: Date.now() })
|
|
707
|
+
.where(eq(pages.id, id))
|
|
708
|
+
.returning()
|
|
709
|
+
.get();
|
|
710
|
+
if (enabled) {
|
|
711
|
+
scheduleAiJob(context.env.AI_JOB_SCHEDULER, {
|
|
712
|
+
entityTable: "pages",
|
|
713
|
+
entityId: id,
|
|
714
|
+
type: "seo",
|
|
715
|
+
delayMs: 0,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
|
|
719
|
+
queryKeys.pages.list,
|
|
720
|
+
queryKeys.pages.getById(id),
|
|
721
|
+
]);
|
|
722
|
+
return result;
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const setMetaTitle = authed
|
|
726
|
+
.input(z.object({ id: z.number(), metaTitle: z.string() }))
|
|
727
|
+
.handler(async ({ context, input }) => {
|
|
728
|
+
const userId = context.user.id;
|
|
729
|
+
const { id, metaTitle } = input;
|
|
730
|
+
const access = await assertPageAccess(context.db, id, userId);
|
|
731
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
732
|
+
|
|
733
|
+
const result = await context.db
|
|
734
|
+
.update(pages)
|
|
735
|
+
.set({ metaTitle, updatedAt: Date.now() })
|
|
736
|
+
.where(eq(pages.id, id))
|
|
737
|
+
.returning()
|
|
738
|
+
.get();
|
|
739
|
+
broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
|
|
740
|
+
queryKeys.pages.list,
|
|
741
|
+
queryKeys.pages.getById(id),
|
|
742
|
+
]);
|
|
743
|
+
return result;
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const setMetaDescription = authed
|
|
747
|
+
.input(z.object({ id: z.number(), metaDescription: z.string() }))
|
|
748
|
+
.handler(async ({ context, input }) => {
|
|
749
|
+
const userId = context.user.id;
|
|
750
|
+
const { id, metaDescription } = input;
|
|
751
|
+
const access = await assertPageAccess(context.db, id, userId);
|
|
752
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
753
|
+
|
|
754
|
+
const result = await context.db
|
|
755
|
+
.update(pages)
|
|
756
|
+
.set({ metaDescription, updatedAt: Date.now() })
|
|
757
|
+
.where(eq(pages.id, id))
|
|
758
|
+
.returning()
|
|
759
|
+
.get();
|
|
760
|
+
broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
|
|
761
|
+
queryKeys.pages.list,
|
|
762
|
+
queryKeys.pages.getById(id),
|
|
763
|
+
]);
|
|
764
|
+
return result;
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const setLayout = authed
|
|
768
|
+
.input(z.object({ id: z.number(), layoutId: z.number() }))
|
|
769
|
+
.handler(async ({ context, input }) => {
|
|
770
|
+
const userId = context.user.id;
|
|
771
|
+
const { id, layoutId } = input;
|
|
772
|
+
const access = await assertPageAccess(context.db, id, userId);
|
|
773
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
774
|
+
|
|
775
|
+
const result = await context.db
|
|
776
|
+
.update(pages)
|
|
777
|
+
.set({ layoutId, updatedAt: Date.now() })
|
|
778
|
+
.where(eq(pages.id, id))
|
|
779
|
+
.returning()
|
|
780
|
+
.get();
|
|
781
|
+
broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
|
|
782
|
+
queryKeys.pages.list,
|
|
783
|
+
queryKeys.pages.getById(id),
|
|
784
|
+
]);
|
|
785
|
+
return result;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const generateSeo = authed
|
|
789
|
+
.input(z.object({ id: z.number() }))
|
|
790
|
+
.handler(async ({ context, input }) => {
|
|
791
|
+
const userId = context.user.id;
|
|
792
|
+
const { id } = input;
|
|
793
|
+
const access = await assertPageAccess(context.db, id, userId);
|
|
794
|
+
if (!access) throw new ORPCError("NOT_FOUND");
|
|
795
|
+
|
|
796
|
+
await executePageSeo(context.db, context.env.OPEN_ROUTER_API_KEY, id);
|
|
797
|
+
broadcastInvalidation(context.env.ProjectRoom, access.page.projectId, [
|
|
798
|
+
queryKeys.pages.list,
|
|
799
|
+
queryKeys.pages.getById(id),
|
|
800
|
+
]);
|
|
801
|
+
const updated = await context.db.select().from(pages).where(eq(pages.id, id)).get();
|
|
802
|
+
return updated;
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
export const pageProcedures = {
|
|
806
|
+
getByPath,
|
|
807
|
+
getStructure,
|
|
808
|
+
list,
|
|
809
|
+
get,
|
|
810
|
+
create,
|
|
811
|
+
update,
|
|
812
|
+
delete: deleteFn,
|
|
813
|
+
setAiSeo,
|
|
814
|
+
setMetaTitle,
|
|
815
|
+
setMetaDescription,
|
|
816
|
+
setLayout,
|
|
817
|
+
generateSeo,
|
|
818
|
+
};
|