@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,218 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import type { Database } from "../db";
|
|
5
|
+
import {
|
|
6
|
+
blockDefinitions,
|
|
7
|
+
blocks,
|
|
8
|
+
environments,
|
|
9
|
+
files,
|
|
10
|
+
layouts,
|
|
11
|
+
pages,
|
|
12
|
+
repeatableItems,
|
|
13
|
+
} from "../schema";
|
|
14
|
+
|
|
15
|
+
export async function resolveEnvironment(
|
|
16
|
+
db: Database,
|
|
17
|
+
projectId: number,
|
|
18
|
+
environmentName: string,
|
|
19
|
+
options?: { autoCreate?: boolean },
|
|
20
|
+
) {
|
|
21
|
+
let environment = await db
|
|
22
|
+
.select()
|
|
23
|
+
.from(environments)
|
|
24
|
+
.where(and(eq(environments.projectId, projectId), eq(environments.name, environmentName)))
|
|
25
|
+
.get();
|
|
26
|
+
|
|
27
|
+
let created = false;
|
|
28
|
+
|
|
29
|
+
if (!environment && options?.autoCreate) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
environment = await db
|
|
32
|
+
.insert(environments)
|
|
33
|
+
.values({
|
|
34
|
+
projectId,
|
|
35
|
+
name: environmentName,
|
|
36
|
+
type: "development",
|
|
37
|
+
createdAt: now,
|
|
38
|
+
updatedAt: now,
|
|
39
|
+
})
|
|
40
|
+
.returning()
|
|
41
|
+
.get();
|
|
42
|
+
|
|
43
|
+
await forkProductionContent(db, projectId, environment.id);
|
|
44
|
+
created = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!environment) {
|
|
48
|
+
throw new ORPCError("NOT_FOUND", {
|
|
49
|
+
message: `Environment "${environmentName}" not found`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { ...environment, created };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Fork production content into a new environment
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function remapFileIds(content: unknown, fileIdMap: Map<number, number>): unknown {
|
|
60
|
+
if (content === null || content === undefined) return content;
|
|
61
|
+
if (Array.isArray(content)) {
|
|
62
|
+
return content.map((item) => remapFileIds(item, fileIdMap));
|
|
63
|
+
}
|
|
64
|
+
if (typeof content === "object") {
|
|
65
|
+
const obj = content as Record<string, unknown>;
|
|
66
|
+
if ("_fileId" in obj && typeof obj._fileId === "number") {
|
|
67
|
+
const newId = fileIdMap.get(obj._fileId);
|
|
68
|
+
if (newId !== undefined) {
|
|
69
|
+
return { ...obj, _fileId: newId };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const result: Record<string, unknown> = {};
|
|
73
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
74
|
+
result[key] = remapFileIds(value, fileIdMap);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
return content;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function forkProductionContent(db: Database, projectId: number, newEnvironmentId: number) {
|
|
82
|
+
const prodEnv = await db
|
|
83
|
+
.select()
|
|
84
|
+
.from(environments)
|
|
85
|
+
.where(and(eq(environments.projectId, projectId), eq(environments.type, "production")))
|
|
86
|
+
.get();
|
|
87
|
+
if (!prodEnv) return;
|
|
88
|
+
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
|
|
91
|
+
// 1. Block definitions
|
|
92
|
+
const prodDefs = await db
|
|
93
|
+
.select()
|
|
94
|
+
.from(blockDefinitions)
|
|
95
|
+
.where(eq(blockDefinitions.environmentId, prodEnv.id));
|
|
96
|
+
for (const def of prodDefs) {
|
|
97
|
+
const { id: _, ...rest } = def;
|
|
98
|
+
await db
|
|
99
|
+
.insert(blockDefinitions)
|
|
100
|
+
.values({ ...rest, environmentId: newEnvironmentId, createdAt: now, updatedAt: now });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Files (same blob/URL, new environment) — build ID mapping
|
|
104
|
+
const fileIdMap = new Map<number, number>();
|
|
105
|
+
const prodFiles = await db.select().from(files).where(eq(files.environmentId, prodEnv.id));
|
|
106
|
+
for (const file of prodFiles) {
|
|
107
|
+
const { id: _, ...rest } = file;
|
|
108
|
+
const newFile = await db
|
|
109
|
+
.insert(files)
|
|
110
|
+
.values({ ...rest, environmentId: newEnvironmentId, createdAt: now, updatedAt: now })
|
|
111
|
+
.returning()
|
|
112
|
+
.get();
|
|
113
|
+
fileIdMap.set(file.id, newFile.id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. Layouts — build ID mapping
|
|
117
|
+
const layoutIdMap = new Map<number, number>();
|
|
118
|
+
const prodLayouts = await db.select().from(layouts).where(eq(layouts.environmentId, prodEnv.id));
|
|
119
|
+
for (const layout of prodLayouts) {
|
|
120
|
+
const { id: _, ...rest } = layout;
|
|
121
|
+
const newLayout = await db
|
|
122
|
+
.insert(layouts)
|
|
123
|
+
.values({ ...rest, environmentId: newEnvironmentId, createdAt: now, updatedAt: now })
|
|
124
|
+
.returning()
|
|
125
|
+
.get();
|
|
126
|
+
layoutIdMap.set(layout.id, newLayout.id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 4. Layout blocks + their repeatable items
|
|
130
|
+
const blockIdMap = new Map<number, number>();
|
|
131
|
+
for (const [oldLayoutId, newLayoutId] of layoutIdMap) {
|
|
132
|
+
const layoutBlocks = await db.select().from(blocks).where(eq(blocks.layoutId, oldLayoutId));
|
|
133
|
+
for (const block of layoutBlocks) {
|
|
134
|
+
const { id: _, ...rest } = block;
|
|
135
|
+
const newBlock = await db
|
|
136
|
+
.insert(blocks)
|
|
137
|
+
.values({
|
|
138
|
+
...rest,
|
|
139
|
+
layoutId: newLayoutId,
|
|
140
|
+
content: remapFileIds(rest.content, fileIdMap),
|
|
141
|
+
createdAt: now,
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
})
|
|
144
|
+
.returning()
|
|
145
|
+
.get();
|
|
146
|
+
blockIdMap.set(block.id, newBlock.id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 5. Pages — sort by id so parents are inserted before children
|
|
151
|
+
const pageIdMap = new Map<number, number>();
|
|
152
|
+
const prodPages = await db.select().from(pages).where(eq(pages.environmentId, prodEnv.id));
|
|
153
|
+
prodPages.sort((a, b) => a.id - b.id);
|
|
154
|
+
|
|
155
|
+
for (const page of prodPages) {
|
|
156
|
+
const { id: _, ...rest } = page;
|
|
157
|
+
const newPage = await db
|
|
158
|
+
.insert(pages)
|
|
159
|
+
.values({
|
|
160
|
+
...rest,
|
|
161
|
+
environmentId: newEnvironmentId,
|
|
162
|
+
layoutId: layoutIdMap.get(page.layoutId) ?? page.layoutId,
|
|
163
|
+
parentPageId: page.parentPageId ? (pageIdMap.get(page.parentPageId) ?? null) : null,
|
|
164
|
+
createdAt: now,
|
|
165
|
+
updatedAt: now,
|
|
166
|
+
})
|
|
167
|
+
.returning()
|
|
168
|
+
.get();
|
|
169
|
+
pageIdMap.set(page.id, newPage.id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 6. Page blocks
|
|
173
|
+
for (const [oldPageId, newPageId] of pageIdMap) {
|
|
174
|
+
const pageBlocks = await db.select().from(blocks).where(eq(blocks.pageId, oldPageId));
|
|
175
|
+
for (const block of pageBlocks) {
|
|
176
|
+
const { id: _, ...rest } = block;
|
|
177
|
+
const newBlock = await db
|
|
178
|
+
.insert(blocks)
|
|
179
|
+
.values({
|
|
180
|
+
...rest,
|
|
181
|
+
pageId: newPageId,
|
|
182
|
+
content: remapFileIds(rest.content, fileIdMap),
|
|
183
|
+
createdAt: now,
|
|
184
|
+
updatedAt: now,
|
|
185
|
+
})
|
|
186
|
+
.returning()
|
|
187
|
+
.get();
|
|
188
|
+
blockIdMap.set(block.id, newBlock.id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 7. Repeatable items for all copied blocks — sort by id so parents come first
|
|
193
|
+
for (const [oldBlockId, newBlockId] of blockIdMap) {
|
|
194
|
+
const items = await db
|
|
195
|
+
.select()
|
|
196
|
+
.from(repeatableItems)
|
|
197
|
+
.where(eq(repeatableItems.blockId, oldBlockId));
|
|
198
|
+
items.sort((a, b) => a.id - b.id);
|
|
199
|
+
|
|
200
|
+
const itemIdMap = new Map<number, number>();
|
|
201
|
+
for (const item of items) {
|
|
202
|
+
const { id: _, ...rest } = item;
|
|
203
|
+
const newItem = await db
|
|
204
|
+
.insert(repeatableItems)
|
|
205
|
+
.values({
|
|
206
|
+
...rest,
|
|
207
|
+
blockId: newBlockId,
|
|
208
|
+
parentItemId: item.parentItemId ? (itemIdMap.get(item.parentItemId) ?? null) : null,
|
|
209
|
+
content: remapFileIds(rest.content, fileIdMap),
|
|
210
|
+
createdAt: now,
|
|
211
|
+
updatedAt: now,
|
|
212
|
+
})
|
|
213
|
+
.returning()
|
|
214
|
+
.get();
|
|
215
|
+
itemIdMap.set(item.id, newItem.id);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type AiJobType = "summary" | "fileMetadata" | "seo";
|
|
2
|
+
type EntityTable = "blocks" | "repeatableItems" | "files" | "pages";
|
|
3
|
+
|
|
4
|
+
export function scheduleAiJob(
|
|
5
|
+
doNamespace: DurableObjectNamespace,
|
|
6
|
+
options: {
|
|
7
|
+
entityTable: EntityTable;
|
|
8
|
+
entityId: number;
|
|
9
|
+
type: AiJobType;
|
|
10
|
+
delayMs: number;
|
|
11
|
+
},
|
|
12
|
+
) {
|
|
13
|
+
const name = `${options.entityTable}:${options.entityId}:${options.type}`;
|
|
14
|
+
const id = doNamespace.idFromName(name);
|
|
15
|
+
const stub = doNamespace.get(id);
|
|
16
|
+
return stub.fetch("http://do/schedule", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify(options),
|
|
20
|
+
});
|
|
21
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import {
|
|
3
|
+
adjectives,
|
|
4
|
+
animals,
|
|
5
|
+
NumberDictionary,
|
|
6
|
+
uniqueNamesGenerator,
|
|
7
|
+
} from "unique-names-generator";
|
|
8
|
+
|
|
9
|
+
import type { Database } from "../db";
|
|
10
|
+
import { projects } from "../schema";
|
|
11
|
+
|
|
12
|
+
const numberDictionary = NumberDictionary.generate({ min: 10, max: 99 });
|
|
13
|
+
|
|
14
|
+
function generateSlug(): string {
|
|
15
|
+
return uniqueNamesGenerator({
|
|
16
|
+
dictionaries: [adjectives, animals, numberDictionary],
|
|
17
|
+
separator: "-",
|
|
18
|
+
style: "lowerCase",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generates a unique human-readable slug (e.g. "swift-falcon-42")
|
|
24
|
+
* and verifies it doesn't collide with an existing project slug.
|
|
25
|
+
* Retries up to 10 times on collision.
|
|
26
|
+
*/
|
|
27
|
+
export async function generateUniqueSlug(db: Database): Promise<string> {
|
|
28
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
29
|
+
const slug = generateSlug();
|
|
30
|
+
const existing = await db
|
|
31
|
+
.select({ id: projects.id })
|
|
32
|
+
.from(projects)
|
|
33
|
+
.where(eq(projects.slug, slug))
|
|
34
|
+
.get();
|
|
35
|
+
|
|
36
|
+
if (!existing) {
|
|
37
|
+
return slug;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error("Failed to generate a unique slug after 10 attempts");
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
|
|
3
|
+
import type { AppEnv } from "./types";
|
|
4
|
+
|
|
5
|
+
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
|
6
|
+
if (!c.var.user) {
|
|
7
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
8
|
+
}
|
|
9
|
+
await next();
|
|
10
|
+
});
|
package/src/orpc.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { os, ORPCError } from "@orpc/server";
|
|
2
|
+
|
|
3
|
+
import type { Database } from "./db";
|
|
4
|
+
import type { Auth } from "./routes/auth";
|
|
5
|
+
import type { Bindings } from "./types";
|
|
6
|
+
|
|
7
|
+
// --- Context types ---
|
|
8
|
+
|
|
9
|
+
export type BaseContext = {
|
|
10
|
+
db: Database;
|
|
11
|
+
user: Auth["$Infer"]["Session"]["user"] | null;
|
|
12
|
+
session: Auth["$Infer"]["Session"]["session"] | null;
|
|
13
|
+
env: Bindings;
|
|
14
|
+
headers: Headers;
|
|
15
|
+
environmentName: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AuthedContext = BaseContext & {
|
|
19
|
+
user: Auth["$Infer"]["Session"]["user"];
|
|
20
|
+
session: Auth["$Infer"]["Session"]["session"];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// --- Base procedures ---
|
|
24
|
+
|
|
25
|
+
/** Public procedure — available to anyone, no auth required */
|
|
26
|
+
export const pub = os.$context<BaseContext>().use(async ({ next, path }) => {
|
|
27
|
+
try {
|
|
28
|
+
return await next();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof ORPCError && error.status < 500) {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
console.error(`[oRPC] ${path.join(".")} →`, error);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Sync procedure — requires either a valid session or the x-sync-secret header */
|
|
39
|
+
export const synced = pub.use(async ({ context, next }) => {
|
|
40
|
+
if (context.user && context.session) {
|
|
41
|
+
return next({ context });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const secret = context.headers.get("x-sync-secret");
|
|
45
|
+
if (!secret || secret !== context.env.SYNC_SECRET) {
|
|
46
|
+
throw new ORPCError("UNAUTHORIZED");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return next({ context });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Authed procedure — requires authenticated user */
|
|
53
|
+
export const authed = pub.use(async ({ context, next }) => {
|
|
54
|
+
if (!context.user || !context.session) {
|
|
55
|
+
throw new ORPCError("UNAUTHORIZED");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return next({
|
|
59
|
+
context: {
|
|
60
|
+
...context,
|
|
61
|
+
user: context.user,
|
|
62
|
+
session: context.session,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { blockDefinitionProcedures } from "./routes/block-definitions";
|
|
2
|
+
import { blockProcedures } from "./routes/blocks";
|
|
3
|
+
import { fileProcedures } from "./routes/files";
|
|
4
|
+
import { layoutProcedures } from "./routes/layouts";
|
|
5
|
+
import { pageProcedures } from "./routes/pages";
|
|
6
|
+
import { projectProcedures } from "./routes/projects";
|
|
7
|
+
import { repeatableItemProcedures } from "./routes/repeatable-items";
|
|
8
|
+
|
|
9
|
+
export const router = {
|
|
10
|
+
projects: projectProcedures,
|
|
11
|
+
pages: pageProcedures,
|
|
12
|
+
blocks: blockProcedures,
|
|
13
|
+
layouts: layoutProcedures,
|
|
14
|
+
files: fileProcedures,
|
|
15
|
+
repeatableItems: repeatableItemProcedures,
|
|
16
|
+
blockDefinitions: blockDefinitionProcedures,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type Router = typeof router;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
3
|
+
import { bearer, oneTimeToken, organization } from "better-auth/plugins";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { cors } from "hono/cors";
|
|
6
|
+
|
|
7
|
+
import type { Database } from "../db";
|
|
8
|
+
import { crossDomain } from "../lib/cross-domain";
|
|
9
|
+
import {
|
|
10
|
+
user,
|
|
11
|
+
session,
|
|
12
|
+
account,
|
|
13
|
+
verification,
|
|
14
|
+
organizationTable,
|
|
15
|
+
member,
|
|
16
|
+
invitation,
|
|
17
|
+
} from "../schema";
|
|
18
|
+
import type { AppEnv, Bindings } from "../types";
|
|
19
|
+
|
|
20
|
+
// --- Auth Factory ---
|
|
21
|
+
|
|
22
|
+
const authSchema = {
|
|
23
|
+
user,
|
|
24
|
+
session,
|
|
25
|
+
account,
|
|
26
|
+
verification,
|
|
27
|
+
organization: organizationTable,
|
|
28
|
+
member,
|
|
29
|
+
invitation,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function generateSlug(name: string): string {
|
|
33
|
+
const base = name
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
36
|
+
.replace(/^-|-$/g, "");
|
|
37
|
+
const suffix = crypto.randomUUID().slice(0, 8);
|
|
38
|
+
return `${base}-${suffix}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createAuth(db: Database, env: Bindings) {
|
|
42
|
+
const auth = betterAuth({
|
|
43
|
+
database: drizzleAdapter(db, {
|
|
44
|
+
provider: "sqlite",
|
|
45
|
+
schema: authSchema,
|
|
46
|
+
}),
|
|
47
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
48
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
49
|
+
emailAndPassword: {
|
|
50
|
+
enabled: true,
|
|
51
|
+
requireEmailVerification: false,
|
|
52
|
+
},
|
|
53
|
+
socialProviders: {
|
|
54
|
+
github: {
|
|
55
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
56
|
+
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
57
|
+
},
|
|
58
|
+
google: {
|
|
59
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
60
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
session: {
|
|
64
|
+
expiresIn: 60 * 60 * 24 * 90, // 90 days
|
|
65
|
+
updateAge: 60 * 60 * 24, // refresh session expiry daily
|
|
66
|
+
},
|
|
67
|
+
// Accept requests from any origin — Camox sites run on arbitrary customer domains
|
|
68
|
+
trustedOrigins: ["*"],
|
|
69
|
+
plugins: [organization(), crossDomain({ siteUrl: env.SITE_URL }), oneTimeToken(), bearer()],
|
|
70
|
+
databaseHooks: {
|
|
71
|
+
user: {
|
|
72
|
+
create: {
|
|
73
|
+
after: async (user) => {
|
|
74
|
+
const orgName = `${user.name}'s team`;
|
|
75
|
+
const slug = generateSlug(orgName);
|
|
76
|
+
await auth.api.createOrganization({
|
|
77
|
+
body: {
|
|
78
|
+
name: orgName,
|
|
79
|
+
slug,
|
|
80
|
+
userId: user.id,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
return auth;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type Auth = ReturnType<typeof createAuth>;
|
|
92
|
+
|
|
93
|
+
// --- Routes ---
|
|
94
|
+
|
|
95
|
+
export const authRoutes = new Hono<AppEnv>()
|
|
96
|
+
.use(
|
|
97
|
+
"*",
|
|
98
|
+
cors({
|
|
99
|
+
origin: (origin) => origin,
|
|
100
|
+
allowHeaders: ["Content-Type", "Authorization"],
|
|
101
|
+
allowMethods: ["POST", "GET", "OPTIONS"],
|
|
102
|
+
exposeHeaders: ["Content-Length", "Set-Better-Auth-Cookie"],
|
|
103
|
+
maxAge: 600,
|
|
104
|
+
credentials: true,
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
.on(["POST", "GET"], "/*", async (c) => {
|
|
108
|
+
const auth = createAuth(c.var.db, c.env);
|
|
109
|
+
return auth.handler(c.req.raw);
|
|
110
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { resolveEnvironment } from "../lib/resolve-environment";
|
|
6
|
+
import { pub, synced } from "../orpc";
|
|
7
|
+
import { blockDefinitions, projects } from "../schema";
|
|
8
|
+
|
|
9
|
+
// --- Procedures ---
|
|
10
|
+
|
|
11
|
+
const definitionSchema = z.object({
|
|
12
|
+
projectSlug: z.string(),
|
|
13
|
+
blockId: z.string(),
|
|
14
|
+
title: z.string(),
|
|
15
|
+
description: z.string(),
|
|
16
|
+
contentSchema: z.unknown(),
|
|
17
|
+
settingsSchema: z.unknown().optional(),
|
|
18
|
+
defaultContent: z.unknown().optional(),
|
|
19
|
+
defaultSettings: z.unknown().optional(),
|
|
20
|
+
layoutOnly: z.boolean().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const syncSchema = z.object({
|
|
24
|
+
projectSlug: z.string(),
|
|
25
|
+
definitions: z.array(
|
|
26
|
+
z.object({
|
|
27
|
+
blockId: z.string(),
|
|
28
|
+
title: z.string(),
|
|
29
|
+
description: z.string(),
|
|
30
|
+
contentSchema: z.unknown(),
|
|
31
|
+
settingsSchema: z.unknown().optional(),
|
|
32
|
+
defaultContent: z.unknown().optional(),
|
|
33
|
+
defaultSettings: z.unknown().optional(),
|
|
34
|
+
layoutOnly: z.boolean().optional(),
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const list = pub.input(z.object({ projectId: z.number() })).handler(async ({ context, input }) => {
|
|
40
|
+
const environment = await resolveEnvironment(
|
|
41
|
+
context.db,
|
|
42
|
+
input.projectId,
|
|
43
|
+
context.environmentName,
|
|
44
|
+
);
|
|
45
|
+
const result = await context.db
|
|
46
|
+
.select()
|
|
47
|
+
.from(blockDefinitions)
|
|
48
|
+
.where(
|
|
49
|
+
and(
|
|
50
|
+
eq(blockDefinitions.projectId, input.projectId),
|
|
51
|
+
eq(blockDefinitions.environmentId, environment.id),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
return result;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const sync = synced.input(syncSchema).handler(async ({ context, input }) => {
|
|
58
|
+
const { projectSlug, definitions } = input;
|
|
59
|
+
const project = await context.db
|
|
60
|
+
.select()
|
|
61
|
+
.from(projects)
|
|
62
|
+
.where(eq(projects.slug, projectSlug))
|
|
63
|
+
.get();
|
|
64
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
65
|
+
const projectId = project.id;
|
|
66
|
+
const environment = await resolveEnvironment(context.db, projectId, context.environmentName, {
|
|
67
|
+
autoCreate: true,
|
|
68
|
+
});
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const results = [];
|
|
71
|
+
|
|
72
|
+
for (const def of definitions) {
|
|
73
|
+
const existing = await context.db
|
|
74
|
+
.select()
|
|
75
|
+
.from(blockDefinitions)
|
|
76
|
+
.where(
|
|
77
|
+
and(
|
|
78
|
+
eq(blockDefinitions.projectId, projectId),
|
|
79
|
+
eq(blockDefinitions.environmentId, environment.id),
|
|
80
|
+
eq(blockDefinitions.blockId, def.blockId),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
.get();
|
|
84
|
+
|
|
85
|
+
if (existing) {
|
|
86
|
+
const updated = await context.db
|
|
87
|
+
.update(blockDefinitions)
|
|
88
|
+
.set({
|
|
89
|
+
title: def.title,
|
|
90
|
+
description: def.description,
|
|
91
|
+
contentSchema: def.contentSchema,
|
|
92
|
+
settingsSchema: def.settingsSchema ?? null,
|
|
93
|
+
defaultContent: def.defaultContent ?? null,
|
|
94
|
+
defaultSettings: def.defaultSettings ?? null,
|
|
95
|
+
layoutOnly: def.layoutOnly ?? null,
|
|
96
|
+
updatedAt: now,
|
|
97
|
+
})
|
|
98
|
+
.where(eq(blockDefinitions.id, existing.id))
|
|
99
|
+
.returning()
|
|
100
|
+
.get();
|
|
101
|
+
results.push(updated);
|
|
102
|
+
} else {
|
|
103
|
+
const created = await context.db
|
|
104
|
+
.insert(blockDefinitions)
|
|
105
|
+
.values({
|
|
106
|
+
projectId,
|
|
107
|
+
environmentId: environment.id,
|
|
108
|
+
blockId: def.blockId,
|
|
109
|
+
title: def.title,
|
|
110
|
+
description: def.description,
|
|
111
|
+
contentSchema: def.contentSchema,
|
|
112
|
+
settingsSchema: def.settingsSchema ?? null,
|
|
113
|
+
defaultContent: def.defaultContent ?? null,
|
|
114
|
+
defaultSettings: def.defaultSettings ?? null,
|
|
115
|
+
layoutOnly: def.layoutOnly ?? null,
|
|
116
|
+
createdAt: now,
|
|
117
|
+
updatedAt: now,
|
|
118
|
+
})
|
|
119
|
+
.returning()
|
|
120
|
+
.get();
|
|
121
|
+
results.push(created);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { results, environmentCreated: environment.created };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const upsert = synced.input(definitionSchema).handler(async ({ context, input }) => {
|
|
129
|
+
const { projectSlug, ...body } = input;
|
|
130
|
+
const project = await context.db
|
|
131
|
+
.select()
|
|
132
|
+
.from(projects)
|
|
133
|
+
.where(eq(projects.slug, projectSlug))
|
|
134
|
+
.get();
|
|
135
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
136
|
+
const projectId = project.id;
|
|
137
|
+
const environment = await resolveEnvironment(context.db, projectId, context.environmentName, {
|
|
138
|
+
autoCreate: true,
|
|
139
|
+
});
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
|
|
142
|
+
const existing = await context.db
|
|
143
|
+
.select()
|
|
144
|
+
.from(blockDefinitions)
|
|
145
|
+
.where(
|
|
146
|
+
and(
|
|
147
|
+
eq(blockDefinitions.projectId, projectId),
|
|
148
|
+
eq(blockDefinitions.environmentId, environment.id),
|
|
149
|
+
eq(blockDefinitions.blockId, body.blockId),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
.get();
|
|
153
|
+
|
|
154
|
+
if (existing) {
|
|
155
|
+
const result = await context.db
|
|
156
|
+
.update(blockDefinitions)
|
|
157
|
+
.set({
|
|
158
|
+
title: body.title,
|
|
159
|
+
description: body.description,
|
|
160
|
+
contentSchema: body.contentSchema,
|
|
161
|
+
settingsSchema: body.settingsSchema ?? null,
|
|
162
|
+
defaultContent: body.defaultContent ?? null,
|
|
163
|
+
defaultSettings: body.defaultSettings ?? null,
|
|
164
|
+
layoutOnly: body.layoutOnly ?? null,
|
|
165
|
+
updatedAt: now,
|
|
166
|
+
})
|
|
167
|
+
.where(eq(blockDefinitions.id, existing.id))
|
|
168
|
+
.returning()
|
|
169
|
+
.get();
|
|
170
|
+
return { ...result, action: "updated" as const };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result = await context.db
|
|
174
|
+
.insert(blockDefinitions)
|
|
175
|
+
.values({
|
|
176
|
+
...body,
|
|
177
|
+
projectId,
|
|
178
|
+
environmentId: environment.id,
|
|
179
|
+
settingsSchema: body.settingsSchema ?? null,
|
|
180
|
+
defaultContent: body.defaultContent ?? null,
|
|
181
|
+
defaultSettings: body.defaultSettings ?? null,
|
|
182
|
+
layoutOnly: body.layoutOnly ?? null,
|
|
183
|
+
createdAt: now,
|
|
184
|
+
updatedAt: now,
|
|
185
|
+
})
|
|
186
|
+
.returning()
|
|
187
|
+
.get();
|
|
188
|
+
return { ...result, action: "created" as const };
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const deleteFn = synced
|
|
192
|
+
.input(z.object({ projectSlug: z.string(), blockId: z.string() }))
|
|
193
|
+
.handler(async ({ context, input }) => {
|
|
194
|
+
const { projectSlug, blockId } = input;
|
|
195
|
+
const project = await context.db
|
|
196
|
+
.select()
|
|
197
|
+
.from(projects)
|
|
198
|
+
.where(eq(projects.slug, projectSlug))
|
|
199
|
+
.get();
|
|
200
|
+
if (!project) throw new ORPCError("NOT_FOUND");
|
|
201
|
+
const environment = await resolveEnvironment(context.db, project.id, context.environmentName);
|
|
202
|
+
const result = await context.db
|
|
203
|
+
.delete(blockDefinitions)
|
|
204
|
+
.where(
|
|
205
|
+
and(
|
|
206
|
+
eq(blockDefinitions.projectId, project.id),
|
|
207
|
+
eq(blockDefinitions.environmentId, environment.id),
|
|
208
|
+
eq(blockDefinitions.blockId, blockId),
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
.returning()
|
|
212
|
+
.get();
|
|
213
|
+
return { deleted: !!result, blockId };
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
export const blockDefinitionProcedures = { list, sync, upsert, delete: deleteFn };
|