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