@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,267 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { assertOrgMembership, getAuthorizedProject } from "../authorization";
|
|
7
|
+
import { resolveEnvironment } from "../lib/resolve-environment";
|
|
8
|
+
import { generateUniqueSlug } from "../lib/slug";
|
|
9
|
+
import { authed, synced } from "../orpc";
|
|
10
|
+
import { blocks, environments, layouts, pages, projects, repeatableItems } from "../schema";
|
|
11
|
+
|
|
12
|
+
// --- Procedures ---
|
|
13
|
+
|
|
14
|
+
const createProjectSchema = z.object({
|
|
15
|
+
name: z.string(),
|
|
16
|
+
organizationSlug: z.string(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const updateProjectSchema = z.object({
|
|
20
|
+
name: z.string(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const list = authed
|
|
24
|
+
.input(z.object({ organizationSlug: z.string() }))
|
|
25
|
+
.handler(async ({ context, input }) => {
|
|
26
|
+
await assertOrgMembership(context.db, context.user.id, input.organizationSlug);
|
|
27
|
+
return context.db
|
|
28
|
+
.select()
|
|
29
|
+
.from(projects)
|
|
30
|
+
.where(eq(projects.organizationSlug, input.organizationSlug));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const getFirst = authed
|
|
34
|
+
.input(z.object({ organizationSlug: z.string() }))
|
|
35
|
+
.handler(async ({ context, input }) => {
|
|
36
|
+
await assertOrgMembership(context.db, context.user.id, input.organizationSlug);
|
|
37
|
+
const result = await context.db
|
|
38
|
+
.select()
|
|
39
|
+
.from(projects)
|
|
40
|
+
.where(eq(projects.organizationSlug, input.organizationSlug))
|
|
41
|
+
.limit(1)
|
|
42
|
+
.get();
|
|
43
|
+
if (!result) throw new ORPCError("NOT_FOUND");
|
|
44
|
+
return result;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const getBySlug = authed
|
|
48
|
+
.input(z.object({ slug: z.string() }))
|
|
49
|
+
.handler(async ({ context, input }) => {
|
|
50
|
+
const result = await context.db
|
|
51
|
+
.select()
|
|
52
|
+
.from(projects)
|
|
53
|
+
.where(eq(projects.slug, input.slug))
|
|
54
|
+
.get();
|
|
55
|
+
if (!result) throw new ORPCError("NOT_FOUND");
|
|
56
|
+
await assertOrgMembership(context.db, context.user.id, result.organizationSlug);
|
|
57
|
+
return result;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const get = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
61
|
+
const result = await context.db.select().from(projects).where(eq(projects.id, input.id)).get();
|
|
62
|
+
if (!result) throw new ORPCError("NOT_FOUND");
|
|
63
|
+
await assertOrgMembership(context.db, context.user.id, result.organizationSlug);
|
|
64
|
+
return result;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const create = authed.input(createProjectSchema).handler(async ({ context, input }) => {
|
|
68
|
+
await assertOrgMembership(context.db, context.user.id, input.organizationSlug);
|
|
69
|
+
|
|
70
|
+
const slug = await generateUniqueSlug(context.db);
|
|
71
|
+
const syncSecret = crypto.randomUUID();
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
|
|
74
|
+
const result = await context.db
|
|
75
|
+
.insert(projects)
|
|
76
|
+
.values({
|
|
77
|
+
name: input.name,
|
|
78
|
+
slug,
|
|
79
|
+
syncSecret,
|
|
80
|
+
organizationSlug: input.organizationSlug,
|
|
81
|
+
createdAt: now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
})
|
|
84
|
+
.returning()
|
|
85
|
+
.get();
|
|
86
|
+
|
|
87
|
+
await context.db.insert(environments).values({
|
|
88
|
+
projectId: result.id,
|
|
89
|
+
name: "production",
|
|
90
|
+
type: "production",
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const update = authed
|
|
99
|
+
.input(updateProjectSchema.extend({ id: z.number() }))
|
|
100
|
+
.handler(async ({ context, input }) => {
|
|
101
|
+
const { id, ...body } = input;
|
|
102
|
+
const project = await getAuthorizedProject(context.db, id, context.user.id);
|
|
103
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
104
|
+
const result = await context.db
|
|
105
|
+
.update(projects)
|
|
106
|
+
.set({ ...body, updatedAt: Date.now() })
|
|
107
|
+
.where(eq(projects.id, id))
|
|
108
|
+
.returning()
|
|
109
|
+
.get();
|
|
110
|
+
return result;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const deleteFn = authed.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
|
|
114
|
+
const project = await getAuthorizedProject(context.db, input.id, context.user.id);
|
|
115
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
116
|
+
const result = await context.db
|
|
117
|
+
.delete(projects)
|
|
118
|
+
.where(eq(projects.id, input.id))
|
|
119
|
+
.returning()
|
|
120
|
+
.get();
|
|
121
|
+
return result;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const repeatableItemSeedSchema = z.object({
|
|
125
|
+
tempId: z.string(),
|
|
126
|
+
parentTempId: z.string().nullable(),
|
|
127
|
+
fieldName: z.string(),
|
|
128
|
+
content: z.unknown(),
|
|
129
|
+
position: z.string(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const initializeContentSchema = z.object({
|
|
133
|
+
projectSlug: z.string(),
|
|
134
|
+
layoutId: z.string(),
|
|
135
|
+
blocks: z.array(
|
|
136
|
+
z.object({
|
|
137
|
+
type: z.string(),
|
|
138
|
+
content: z.unknown(),
|
|
139
|
+
settings: z.unknown().optional(),
|
|
140
|
+
repeatableItems: z.array(repeatableItemSeedSchema).optional(),
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const initializeContent = synced
|
|
146
|
+
.input(initializeContentSchema)
|
|
147
|
+
.handler(async ({ context, input }) => {
|
|
148
|
+
const project = await context.db
|
|
149
|
+
.select()
|
|
150
|
+
.from(projects)
|
|
151
|
+
.where(eq(projects.slug, input.projectSlug))
|
|
152
|
+
.get();
|
|
153
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
154
|
+
|
|
155
|
+
const environment = await resolveEnvironment(context.db, project.id, context.environmentName);
|
|
156
|
+
|
|
157
|
+
// Check if environment already has pages — if so, skip (idempotent)
|
|
158
|
+
const existingPage = await context.db
|
|
159
|
+
.select()
|
|
160
|
+
.from(pages)
|
|
161
|
+
.where(eq(pages.environmentId, environment.id))
|
|
162
|
+
.limit(1)
|
|
163
|
+
.get();
|
|
164
|
+
if (existingPage) {
|
|
165
|
+
return { created: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
|
|
170
|
+
// Find the specified layout
|
|
171
|
+
const layout = await context.db
|
|
172
|
+
.select()
|
|
173
|
+
.from(layouts)
|
|
174
|
+
.where(
|
|
175
|
+
and(
|
|
176
|
+
eq(layouts.projectId, project.id),
|
|
177
|
+
eq(layouts.environmentId, environment.id),
|
|
178
|
+
eq(layouts.layoutId, input.layoutId),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
.get();
|
|
182
|
+
if (!layout) {
|
|
183
|
+
return { created: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Create homepage
|
|
187
|
+
const homepage = await context.db
|
|
188
|
+
.insert(pages)
|
|
189
|
+
.values({
|
|
190
|
+
projectId: project.id,
|
|
191
|
+
environmentId: environment.id,
|
|
192
|
+
pathSegment: "",
|
|
193
|
+
fullPath: "/",
|
|
194
|
+
layoutId: layout.id,
|
|
195
|
+
metaTitle: "Untitled page",
|
|
196
|
+
metaDescription:
|
|
197
|
+
"Title and description will be generated by AI as you edit the page's content.",
|
|
198
|
+
createdAt: now,
|
|
199
|
+
updatedAt: now,
|
|
200
|
+
})
|
|
201
|
+
.returning()
|
|
202
|
+
.get();
|
|
203
|
+
|
|
204
|
+
// Create blocks on the homepage
|
|
205
|
+
let prevPosition: string | null = null;
|
|
206
|
+
let blockCount = 0;
|
|
207
|
+
|
|
208
|
+
for (const blockDef of input.blocks) {
|
|
209
|
+
const position = generateKeyBetween(prevPosition, null);
|
|
210
|
+
prevPosition = position;
|
|
211
|
+
|
|
212
|
+
const block = await context.db
|
|
213
|
+
.insert(blocks)
|
|
214
|
+
.values({
|
|
215
|
+
pageId: homepage.id,
|
|
216
|
+
type: blockDef.type,
|
|
217
|
+
content: blockDef.content,
|
|
218
|
+
settings: blockDef.settings ?? null,
|
|
219
|
+
position,
|
|
220
|
+
summary: "",
|
|
221
|
+
createdAt: now,
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
})
|
|
224
|
+
.returning()
|
|
225
|
+
.get();
|
|
226
|
+
|
|
227
|
+
const itemSeeds = blockDef.repeatableItems;
|
|
228
|
+
if (itemSeeds && itemSeeds.length > 0) {
|
|
229
|
+
const tempIdToRealId = new Map<string, number>();
|
|
230
|
+
for (const seed of itemSeeds) {
|
|
231
|
+
const parentItemId = seed.parentTempId
|
|
232
|
+
? (tempIdToRealId.get(seed.parentTempId) ?? null)
|
|
233
|
+
: null;
|
|
234
|
+
const inserted = await context.db
|
|
235
|
+
.insert(repeatableItems)
|
|
236
|
+
.values({
|
|
237
|
+
blockId: block.id,
|
|
238
|
+
parentItemId,
|
|
239
|
+
fieldName: seed.fieldName,
|
|
240
|
+
content: seed.content,
|
|
241
|
+
summary: "",
|
|
242
|
+
position: seed.position,
|
|
243
|
+
createdAt: now,
|
|
244
|
+
updatedAt: now,
|
|
245
|
+
})
|
|
246
|
+
.returning()
|
|
247
|
+
.get();
|
|
248
|
+
tempIdToRealId.set(seed.tempId, inserted.id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
blockCount++;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { created: true, pageId: homepage.id, blockCount };
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
export const projectProcedures = {
|
|
259
|
+
list,
|
|
260
|
+
getFirst,
|
|
261
|
+
getBySlug,
|
|
262
|
+
get,
|
|
263
|
+
create,
|
|
264
|
+
update,
|
|
265
|
+
delete: deleteFn,
|
|
266
|
+
initializeContent,
|
|
267
|
+
};
|