first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
3
packages/api/biome.json
Normal file
3
packages/api/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
1
packages/api/index.ts
Normal file
1
packages/api/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./src/app";
|
||||
37
packages/api/package.json
Normal file
37
packages/api/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@repo/ai": "workspace:*",
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/config": "workspace:*",
|
||||
"@repo/database": "workspace:*",
|
||||
"@repo/i18n": "workspace:*",
|
||||
"@repo/logs": "workspace:*",
|
||||
"@repo/mail": "workspace:*",
|
||||
"@repo/payments": "workspace:*",
|
||||
"@repo/storage": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"@scalar/hono-api-reference": "^0.9.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"hono": "^4.7.11",
|
||||
"hono-openapi": "^0.4.8",
|
||||
"nanoid": "^5.1.5",
|
||||
"openai": "^5.1.1",
|
||||
"openapi-merge": "^1.3.3",
|
||||
"use-intl": "^4.1.0",
|
||||
"zod": "^3.25.55"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"encoding": "^0.1.13",
|
||||
"prisma": "^6.9.0",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"name": "@repo/api",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"version": "0.0.0"
|
||||
}
|
||||
80
packages/api/src/app.ts
Normal file
80
packages/api/src/app.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { auth } from "@repo/auth";
|
||||
import { config } from "@repo/config";
|
||||
import { getBaseUrl } from "@repo/utils";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import { Hono } from "hono";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import {} from "openapi-merge";
|
||||
import {} from "openapi-merge";
|
||||
import { mergeOpenApiSchemas } from "./lib/openapi-schema";
|
||||
import { corsMiddleware } from "./middleware/cors";
|
||||
import { loggerMiddleware } from "./middleware/logger";
|
||||
import { adminRouter } from "./routes/admin/router";
|
||||
import { aiRouter } from "./routes/ai";
|
||||
import { authRouter } from "./routes/auth";
|
||||
import { contactRouter } from "./routes/contact/router";
|
||||
import { healthRouter } from "./routes/health";
|
||||
import { newsletterRouter } from "./routes/newsletter";
|
||||
import { organizationsRouter } from "./routes/organizations/router";
|
||||
import { paymentsRouter } from "./routes/payments/router";
|
||||
import { uploadsRouter } from "./routes/uploads";
|
||||
import { webhooksRouter } from "./routes/webhooks";
|
||||
|
||||
export const app = new Hono().basePath("/api");
|
||||
|
||||
app.use(loggerMiddleware);
|
||||
app.use(corsMiddleware);
|
||||
|
||||
const appRouter = app
|
||||
.route("/", authRouter)
|
||||
.route("/", webhooksRouter)
|
||||
.route("/", aiRouter)
|
||||
.route("/", uploadsRouter)
|
||||
.route("/", paymentsRouter)
|
||||
.route("/", contactRouter)
|
||||
.route("/", newsletterRouter)
|
||||
.route("/", organizationsRouter)
|
||||
.route("/", adminRouter)
|
||||
.route("/", healthRouter);
|
||||
|
||||
app.get(
|
||||
"/app-openapi",
|
||||
openAPISpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: `${config.appName} API`,
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: getBaseUrl(),
|
||||
description: "API server",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/openapi", async (c) => {
|
||||
const authSchema = await auth.api.generateOpenAPISchema();
|
||||
const appSchema = await (
|
||||
app.request("/api/app-openapi") as Promise<Response>
|
||||
).then((res) => res.json());
|
||||
|
||||
const mergedSchema = mergeOpenApiSchemas({
|
||||
appSchema,
|
||||
authSchema: authSchema as any,
|
||||
});
|
||||
|
||||
return c.json(mergedSchema);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/docs",
|
||||
Scalar({
|
||||
theme: "saturn",
|
||||
url: "/api/openapi",
|
||||
}),
|
||||
);
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
45
packages/api/src/lib/openapi-schema.ts
Normal file
45
packages/api/src/lib/openapi-schema.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { type MergeInput, isErrorResult, merge } from "openapi-merge";
|
||||
|
||||
export function mergeOpenApiSchemas({
|
||||
appSchema,
|
||||
authSchema,
|
||||
}: {
|
||||
appSchema: MergeInput[number]["oas"];
|
||||
authSchema: MergeInput[number]["oas"];
|
||||
}) {
|
||||
const mergedSchema = merge([
|
||||
{
|
||||
oas: appSchema,
|
||||
},
|
||||
{
|
||||
oas: authSchema,
|
||||
pathModification: {
|
||||
prepend: "/api/auth",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (isErrorResult(mergedSchema)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const output = mergedSchema.output;
|
||||
|
||||
Object.entries(output.paths).forEach(([path, pathItem]) => {
|
||||
if (path.startsWith("/api/auth")) {
|
||||
const methods = ["post", "get", "put", "delete", "patch"];
|
||||
methods.forEach((method) => {
|
||||
const methodItem = pathItem[method as keyof typeof pathItem];
|
||||
if (
|
||||
methodItem &&
|
||||
typeof methodItem === "object" &&
|
||||
!Array.isArray(methodItem)
|
||||
) {
|
||||
methodItem.tags = ["Auth"];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
26
packages/api/src/middleware/admin.ts
Normal file
26
packages/api/src/middleware/admin.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { type Session, auth } from "@repo/auth";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
export const adminMiddleware = createMiddleware<{
|
||||
Variables: {
|
||||
session: Session["session"];
|
||||
user: Session["user"];
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (session.user.role !== "admin") {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
c.set("session", session.session);
|
||||
c.set("user", session.user);
|
||||
|
||||
await next();
|
||||
});
|
||||
22
packages/api/src/middleware/auth.ts
Normal file
22
packages/api/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { type Session, auth } from "@repo/auth";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
export const authMiddleware = createMiddleware<{
|
||||
Variables: {
|
||||
session: Session["session"];
|
||||
user: Session["user"];
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("session", session.session);
|
||||
c.set("user", session.user);
|
||||
|
||||
await next();
|
||||
});
|
||||
11
packages/api/src/middleware/cors.ts
Normal file
11
packages/api/src/middleware/cors.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { getBaseUrl } from "@repo/utils";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
export const corsMiddleware = cors({
|
||||
origin: getBaseUrl(),
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
allowMethods: ["POST", "GET", "OPTIONS"],
|
||||
exposeHeaders: ["Content-Length"],
|
||||
maxAge: 600,
|
||||
credentials: true,
|
||||
});
|
||||
18
packages/api/src/middleware/locale.ts
Normal file
18
packages/api/src/middleware/locale.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { config } from "@repo/config";
|
||||
import type { Locale } from "@repo/i18n";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
export const localeMiddleware = createMiddleware<{
|
||||
Variables: {
|
||||
locale: Locale;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const locale =
|
||||
(getCookie(c, config.i18n.localeCookieName) as Locale) ??
|
||||
config.i18n.defaultLocale;
|
||||
|
||||
c.set("locale", locale);
|
||||
|
||||
await next();
|
||||
});
|
||||
6
packages/api/src/middleware/logger.ts
Normal file
6
packages/api/src/middleware/logger.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { logger } from "@repo/logs";
|
||||
import { logger as honoLogger } from "hono/logger";
|
||||
|
||||
export const loggerMiddleware = honoLogger((message, ...rest) => {
|
||||
logger.log(message, ...rest);
|
||||
});
|
||||
54
packages/api/src/routes/admin/organizations.ts
Normal file
54
packages/api/src/routes/admin/organizations.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
countAllOrganizations,
|
||||
getOrganizationById,
|
||||
getOrganizations,
|
||||
} from "@repo/database";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { z } from "zod";
|
||||
import { adminMiddleware } from "../../middleware/admin";
|
||||
|
||||
export const organizationRouter = new Hono()
|
||||
.basePath("/organizations")
|
||||
.use(adminMiddleware)
|
||||
.get(
|
||||
"/",
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string().optional(),
|
||||
limit: z.string().optional().default("10").transform(Number),
|
||||
offset: z.string().optional().default("0").transform(Number),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
summary: "Get all organizations",
|
||||
tags: ["Administration"],
|
||||
}),
|
||||
async (c) => {
|
||||
const { query, limit, offset } = c.req.valid("query");
|
||||
|
||||
const organizations = await getOrganizations({
|
||||
limit,
|
||||
offset,
|
||||
query,
|
||||
});
|
||||
|
||||
const total = await countAllOrganizations();
|
||||
|
||||
return c.json({ organizations, total });
|
||||
},
|
||||
)
|
||||
.get("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
|
||||
const organization = await getOrganizationById(id);
|
||||
|
||||
if (!organization) {
|
||||
throw new HTTPException(404);
|
||||
}
|
||||
|
||||
return c.json(organization);
|
||||
});
|
||||
8
packages/api/src/routes/admin/router.ts
Normal file
8
packages/api/src/routes/admin/router.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Hono } from "hono";
|
||||
import { organizationRouter } from "./organizations";
|
||||
import { userRouter } from "./users";
|
||||
|
||||
export const adminRouter = new Hono()
|
||||
.basePath("/admin")
|
||||
.route("/", organizationRouter)
|
||||
.route("/", userRouter);
|
||||
38
packages/api/src/routes/admin/users.ts
Normal file
38
packages/api/src/routes/admin/users.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { countAllUsers, getUsers } from "@repo/database";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { adminMiddleware } from "../../middleware/admin";
|
||||
|
||||
export const userRouter = new Hono()
|
||||
.basePath("/users")
|
||||
.use(adminMiddleware)
|
||||
.get(
|
||||
"/",
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string().optional(),
|
||||
limit: z.string().optional().default("10").transform(Number),
|
||||
offset: z.string().optional().default("0").transform(Number),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
summary: "Get all users",
|
||||
tags: ["Administration"],
|
||||
}),
|
||||
async (c) => {
|
||||
const { query, limit, offset } = c.req.valid("query");
|
||||
|
||||
const users = await getUsers({
|
||||
limit,
|
||||
offset,
|
||||
query,
|
||||
});
|
||||
|
||||
const total = await countAllUsers();
|
||||
|
||||
return c.json({ users, total });
|
||||
},
|
||||
);
|
||||
303
packages/api/src/routes/ai.ts
Normal file
303
packages/api/src/routes/ai.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { streamText, textModel } from "@repo/ai";
|
||||
import {
|
||||
AiChatSchema,
|
||||
createAiChat,
|
||||
deleteAiChat,
|
||||
getAiChatById,
|
||||
getAiChatsByOrganizationId,
|
||||
getAiChatsByUserId,
|
||||
updateAiChat,
|
||||
} from "@repo/database";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { z } from "zod";
|
||||
import { authMiddleware } from "../middleware/auth";
|
||||
import { verifyOrganizationMembership } from "./organizations/lib/membership";
|
||||
|
||||
const MessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
const ChatSchema = AiChatSchema.extend({
|
||||
messages: z.array(MessageSchema),
|
||||
});
|
||||
|
||||
export const aiRouter = new Hono()
|
||||
.basePath("/ai")
|
||||
.use(authMiddleware)
|
||||
.get(
|
||||
"/chats",
|
||||
describeRoute({
|
||||
tags: ["AI"],
|
||||
summary: "Get chats",
|
||||
description: "Get all chats for current user or organization",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chats",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({ chats: z.array(ChatSchema) }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({ organizationId: z.string().optional() }).optional(),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query");
|
||||
const chats = await (query?.organizationId
|
||||
? getAiChatsByOrganizationId({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
organizationId: query?.organizationId,
|
||||
})
|
||||
: getAiChatsByUserId({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
userId: c.get("user").id,
|
||||
}));
|
||||
|
||||
return c.json({ chats });
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/chats/:id",
|
||||
describeRoute({
|
||||
tags: ["AI"],
|
||||
summary: "Get chat",
|
||||
description: "Get a chat by id",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chat",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ chat: ChatSchema })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { id } = c.req.param();
|
||||
|
||||
const chat = await getAiChatById(id);
|
||||
|
||||
if (!chat) {
|
||||
throw new HTTPException(404, { message: "Chat not found" });
|
||||
}
|
||||
|
||||
if (chat.organizationId) {
|
||||
await verifyOrganizationMembership(
|
||||
chat.organizationId,
|
||||
c.get("user").id,
|
||||
);
|
||||
} else if (chat.userId !== c.get("user").id) {
|
||||
throw new HTTPException(403, { message: "Forbidden" });
|
||||
}
|
||||
|
||||
return c.json({ chat });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/chats",
|
||||
describeRoute({
|
||||
tags: ["AI"],
|
||||
summary: "Create chat",
|
||||
description: "Create a new chat",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chat",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ chat: ChatSchema })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
organizationId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { title, organizationId } = c.req.valid("json");
|
||||
const user = c.get("user");
|
||||
|
||||
if (organizationId) {
|
||||
await verifyOrganizationMembership(organizationId, user.id);
|
||||
}
|
||||
|
||||
const chat = await createAiChat({
|
||||
title: title,
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
throw new HTTPException(500, {
|
||||
message: "Failed to create chat",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ chat });
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/chats/:id",
|
||||
describeRoute({
|
||||
tags: ["AI"],
|
||||
summary: "Update chat",
|
||||
description: "Update a chat by id",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chat",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ chat: ChatSchema })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", z.object({ title: z.string().optional() })),
|
||||
async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { title } = c.req.valid("json");
|
||||
const user = c.get("user");
|
||||
|
||||
const chat = await getAiChatById(id);
|
||||
|
||||
if (!chat) {
|
||||
throw new HTTPException(404, { message: "Chat not found" });
|
||||
}
|
||||
|
||||
if (chat.organizationId) {
|
||||
await verifyOrganizationMembership(
|
||||
chat.organizationId,
|
||||
user.id,
|
||||
);
|
||||
} else if (chat.userId !== c.get("user").id) {
|
||||
throw new HTTPException(403, { message: "Forbidden" });
|
||||
}
|
||||
|
||||
const updatedChat = await updateAiChat({
|
||||
id,
|
||||
title,
|
||||
});
|
||||
|
||||
return c.json({ chat: updatedChat });
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/chats/:id",
|
||||
describeRoute({
|
||||
tags: ["AI"],
|
||||
summary: "Delete chat",
|
||||
description: "Delete a chat by id",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Chat deleted",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const user = c.get("user");
|
||||
const chat = await getAiChatById(id);
|
||||
|
||||
if (!chat) {
|
||||
throw new HTTPException(404, { message: "Chat not found" });
|
||||
}
|
||||
|
||||
if (chat.organizationId) {
|
||||
await verifyOrganizationMembership(
|
||||
chat.organizationId,
|
||||
user.id,
|
||||
);
|
||||
} else if (chat.userId !== c.get("user").id) {
|
||||
throw new HTTPException(403, { message: "Forbidden" });
|
||||
}
|
||||
|
||||
await deleteAiChat(id);
|
||||
|
||||
return c.body(null, 204);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/chats/:id/messages",
|
||||
describeRoute({
|
||||
tags: ["AI"],
|
||||
summary: "Add message to chat",
|
||||
description:
|
||||
"Send all messages of the chat to the AI model to get a response",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Returns a stream of the response from the AI model",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
messages: z.array(
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { messages } = c.req.valid("json");
|
||||
const user = c.get("user");
|
||||
|
||||
const chat = await getAiChatById(id);
|
||||
|
||||
if (!chat) {
|
||||
throw new HTTPException(404, { message: "Chat not found" });
|
||||
}
|
||||
|
||||
if (chat.organizationId) {
|
||||
await verifyOrganizationMembership(
|
||||
chat.organizationId,
|
||||
user.id,
|
||||
);
|
||||
} else if (chat.userId !== c.get("user").id) {
|
||||
throw new HTTPException(403, { message: "Forbidden" });
|
||||
}
|
||||
|
||||
const response = streamText({
|
||||
model: textModel,
|
||||
messages,
|
||||
async onFinish({ text }) {
|
||||
await updateAiChat({
|
||||
id,
|
||||
messages: [
|
||||
...messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return response.toDataStreamResponse({
|
||||
sendUsage: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
10
packages/api/src/routes/auth.ts
Normal file
10
packages/api/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { auth } from "@repo/auth";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export const authRouter = new Hono()
|
||||
.get("/auth/**", (c) => {
|
||||
return auth.handler(c.req.raw);
|
||||
})
|
||||
.post("/auth/**", (c) => {
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
51
packages/api/src/routes/contact/router.ts
Normal file
51
packages/api/src/routes/contact/router.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { config } from "@repo/config";
|
||||
import { logger } from "@repo/logs";
|
||||
import { sendEmail } from "@repo/mail";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { localeMiddleware } from "../../middleware/locale";
|
||||
import { contactFormSchema } from "./types";
|
||||
|
||||
export const contactRouter = new Hono().basePath("/contact").post(
|
||||
"/",
|
||||
localeMiddleware,
|
||||
validator("form", contactFormSchema),
|
||||
describeRoute({
|
||||
tags: ["Contact"],
|
||||
summary: "Send a message from the contact form",
|
||||
description: "Send a message with an email and name",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Message sent",
|
||||
},
|
||||
400: {
|
||||
description: "Could not send message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { email, name, message } = c.req.valid("form");
|
||||
const locale = c.get("locale");
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: config.contactForm.to,
|
||||
locale,
|
||||
subject: config.contactForm.subject,
|
||||
text: `Name: ${name}\n\nEmail: ${email}\n\nMessage: ${message}`,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return c.json({ error: "Could not send email" }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
9
packages/api/src/routes/contact/types.ts
Normal file
9
packages/api/src/routes/contact/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const contactFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(3),
|
||||
message: z.string().min(10),
|
||||
});
|
||||
|
||||
export type ContactFormValues = z.infer<typeof contactFormSchema>;
|
||||
13
packages/api/src/routes/health.ts
Normal file
13
packages/api/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
|
||||
export const healthRouter = new Hono().get(
|
||||
"/health",
|
||||
describeRoute({
|
||||
tags: ["Health"],
|
||||
summary: "Health check",
|
||||
description: "Returns 200 if the server is healthy",
|
||||
responses: { 200: { description: "OK" } },
|
||||
}),
|
||||
() => new Response("OK"),
|
||||
);
|
||||
54
packages/api/src/routes/newsletter.ts
Normal file
54
packages/api/src/routes/newsletter.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { logger } from "@repo/logs";
|
||||
import { sendEmail } from "@repo/mail";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { localeMiddleware } from "../middleware/locale";
|
||||
|
||||
export const newsletterRouter = new Hono().basePath("/newsletter").post(
|
||||
"/signup",
|
||||
localeMiddleware,
|
||||
validator(
|
||||
"form",
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
tags: ["Newsletter"],
|
||||
summary: "Sign up for the newsletter",
|
||||
description: "Takes an email and sends a confirmation email",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Email sent",
|
||||
},
|
||||
500: {
|
||||
description: "Could not send email",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ error: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { email } = c.req.valid("form");
|
||||
const locale = c.get("locale");
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
locale,
|
||||
templateId: "newsletterSignup",
|
||||
context: {},
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return c.json({ error: "Could not send email" }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
20
packages/api/src/routes/organizations/lib/membership.ts
Normal file
20
packages/api/src/routes/organizations/lib/membership.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { getOrganizationMembership } from "@repo/database";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
export async function verifyOrganizationMembership(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const membership = await getOrganizationMembership(organizationId, userId);
|
||||
|
||||
if (!membership) {
|
||||
throw new HTTPException(404, {
|
||||
message: "User is not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
organization: membership.organization,
|
||||
role: membership.role,
|
||||
};
|
||||
}
|
||||
55
packages/api/src/routes/organizations/router.ts
Normal file
55
packages/api/src/routes/organizations/router.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { getOrganizationBySlug } from "@repo/database";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
|
||||
export const organizationsRouter = new Hono().basePath("/organizations").get(
|
||||
"/generate-slug",
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
summary: "Generate a slug for an organization",
|
||||
tags: ["Organizations"],
|
||||
}),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("query");
|
||||
|
||||
const baseSlug = slugify(name, {
|
||||
lowercase: true,
|
||||
});
|
||||
|
||||
let slug = baseSlug;
|
||||
let hasAvailableSlug = false;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const existing = await getOrganizationBySlug(slug);
|
||||
|
||||
if (!existing) {
|
||||
hasAvailableSlug = true;
|
||||
break;
|
||||
}
|
||||
|
||||
slug = `${baseSlug}-${nanoid(5)}`;
|
||||
}
|
||||
|
||||
if (!hasAvailableSlug) {
|
||||
return c.json(
|
||||
{
|
||||
error: "No available slug found",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
slug,
|
||||
});
|
||||
},
|
||||
);
|
||||
21
packages/api/src/routes/payments/lib/purchases.ts
Normal file
21
packages/api/src/routes/payments/lib/purchases.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
getPurchasesByOrganizationId,
|
||||
getPurchasesByUserId,
|
||||
} from "@repo/database";
|
||||
|
||||
export const getPurchases = async (
|
||||
props: { organizationId: string } | { userId: string },
|
||||
) => {
|
||||
if ("organizationId" in props) {
|
||||
const { organizationId } = props;
|
||||
const purchases = await getPurchasesByOrganizationId(organizationId);
|
||||
|
||||
return purchases;
|
||||
}
|
||||
|
||||
const { userId } = props;
|
||||
|
||||
const purchases = await getPurchasesByUserId(userId);
|
||||
|
||||
return purchases;
|
||||
};
|
||||
217
packages/api/src/routes/payments/router.ts
Normal file
217
packages/api/src/routes/payments/router.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { type Config, config } from "@repo/config";
|
||||
import { getOrganizationMembership } from "@repo/database";
|
||||
import {
|
||||
PurchaseSchema,
|
||||
getOrganizationById,
|
||||
getPurchaseById,
|
||||
} from "@repo/database";
|
||||
import { logger } from "@repo/logs";
|
||||
import {
|
||||
createCheckoutLink,
|
||||
createCustomerPortalLink,
|
||||
getCustomerIdFromEntity,
|
||||
} from "@repo/payments";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { z } from "zod";
|
||||
import { authMiddleware } from "../../middleware/auth";
|
||||
import { getPurchases } from "./lib/purchases";
|
||||
|
||||
const plans = config.payments.plans as Config["payments"]["plans"];
|
||||
|
||||
export const paymentsRouter = new Hono()
|
||||
.basePath("/payments")
|
||||
.get(
|
||||
"/purchases",
|
||||
authMiddleware,
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
tags: ["Payments"],
|
||||
summary: "Get purchases",
|
||||
description:
|
||||
"Get all purchases of the current user or the provided organization",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Purchases",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(PurchaseSchema)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { organizationId } = c.req.valid("query");
|
||||
const user = c.get("user");
|
||||
|
||||
const purchases = await getPurchases(
|
||||
organizationId
|
||||
? {
|
||||
organizationId,
|
||||
}
|
||||
: { userId: user.id },
|
||||
);
|
||||
|
||||
return c.json(purchases);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/create-checkout-link",
|
||||
authMiddleware,
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
type: z.enum(["one-time", "subscription"]),
|
||||
productId: z.string(),
|
||||
redirectUrl: z.string().optional(),
|
||||
organizationId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
tags: ["Payments"],
|
||||
summary: "Create a checkout link",
|
||||
description:
|
||||
"Creates a checkout link for a one-time or subscription product",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Checkout link",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { productId, redirectUrl, type, organizationId } =
|
||||
c.req.valid("query");
|
||||
const user = c.get("user");
|
||||
|
||||
const customerId = await getCustomerIdFromEntity(
|
||||
organizationId
|
||||
? {
|
||||
organizationId,
|
||||
}
|
||||
: {
|
||||
userId: user.id,
|
||||
},
|
||||
);
|
||||
|
||||
const plan = Object.entries(plans).find(([_planId, plan]) =>
|
||||
plan.prices?.find((price) => price.productId === productId),
|
||||
);
|
||||
const price = plan?.[1].prices?.find(
|
||||
(price) => price.productId === productId,
|
||||
);
|
||||
const trialPeriodDays =
|
||||
price && "trialPeriodDays" in price
|
||||
? price.trialPeriodDays
|
||||
: undefined;
|
||||
|
||||
const organization = organizationId
|
||||
? await getOrganizationById(organizationId)
|
||||
: undefined;
|
||||
|
||||
if (organization === null) {
|
||||
throw new HTTPException(404);
|
||||
}
|
||||
|
||||
const seats =
|
||||
organization && price && "seatBased" in price && price.seatBased
|
||||
? organization.members.length
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const checkoutLink = await createCheckoutLink({
|
||||
type,
|
||||
productId,
|
||||
email: user.email,
|
||||
name: user.name ?? "",
|
||||
redirectUrl,
|
||||
...(organizationId
|
||||
? { organizationId }
|
||||
: { userId: user.id }),
|
||||
trialPeriodDays,
|
||||
seats,
|
||||
customerId: customerId ?? undefined,
|
||||
});
|
||||
|
||||
if (!checkoutLink) {
|
||||
throw new HTTPException(500);
|
||||
}
|
||||
|
||||
return c.json({ checkoutLink });
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
throw new HTTPException(500);
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/create-customer-portal-link",
|
||||
authMiddleware,
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
purchaseId: z.string(),
|
||||
redirectUrl: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
tags: ["Payments"],
|
||||
summary: "Create a customer portal link",
|
||||
description:
|
||||
"Creates a customer portal link for the customer or team. If a purchase is provided, the link will be created for the customer of the purchase.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Customer portal link",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { purchaseId, redirectUrl } = c.req.valid("query");
|
||||
const user = c.get("user");
|
||||
|
||||
const purchase = await getPurchaseById(purchaseId);
|
||||
|
||||
if (!purchase) {
|
||||
throw new HTTPException(403);
|
||||
}
|
||||
|
||||
if (purchase.organizationId) {
|
||||
const userOrganizationMembership =
|
||||
await getOrganizationMembership(
|
||||
purchase.organizationId,
|
||||
user.id,
|
||||
);
|
||||
if (userOrganizationMembership?.role !== "owner") {
|
||||
throw new HTTPException(403);
|
||||
}
|
||||
}
|
||||
|
||||
if (purchase.userId && purchase.userId !== user.id) {
|
||||
throw new HTTPException(403);
|
||||
}
|
||||
|
||||
try {
|
||||
const customerPortalLink = await createCustomerPortalLink({
|
||||
subscriptionId: purchase.subscriptionId ?? undefined,
|
||||
customerId: purchase.customerId,
|
||||
redirectUrl,
|
||||
});
|
||||
|
||||
if (!customerPortalLink) {
|
||||
throw new HTTPException(500);
|
||||
}
|
||||
|
||||
return c.json({ customerPortalLink });
|
||||
} catch (e) {
|
||||
logger.error("Could not create customer portal link", e);
|
||||
throw new HTTPException(500);
|
||||
}
|
||||
},
|
||||
);
|
||||
22
packages/api/src/routes/payments/types.ts
Normal file
22
packages/api/src/routes/payments/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const SubscriptionPlanVariantModel = z.object({
|
||||
id: z.string(),
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
interval: z.string(),
|
||||
interval_count: z.number(),
|
||||
});
|
||||
|
||||
export const SubscriptionPlanModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
storeId: z.string().nullable().optional(),
|
||||
variants: z.array(SubscriptionPlanVariantModel),
|
||||
});
|
||||
|
||||
export type SubscriptionPlan = z.infer<typeof SubscriptionPlanModel>;
|
||||
export type SubscriptionPlanVariant = z.infer<
|
||||
typeof SubscriptionPlanVariantModel
|
||||
>;
|
||||
50
packages/api/src/routes/uploads.ts
Normal file
50
packages/api/src/routes/uploads.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getSignedUploadUrl } from "@repo/storage";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { z } from "zod";
|
||||
import { authMiddleware } from "../middleware/auth";
|
||||
|
||||
export const uploadsRouter = new Hono().basePath("/uploads").post(
|
||||
"/signed-upload-url",
|
||||
authMiddleware,
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
bucket: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
tags: ["Uploads"],
|
||||
summary: "Get a signed upload url",
|
||||
description: "Get a signed upload url for a given bucket and path",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Returns a signed upload url",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ signedUrl: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Not allowed",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { bucket, path } = c.req.valid("query");
|
||||
// ATTENTION: be careful with how you give access to write to the storage
|
||||
// always check if the user has the right to write to the desired bucket before giving them a signed url
|
||||
|
||||
if (bucket === config.storage.bucketNames.avatars) {
|
||||
const signedUrl = await getSignedUploadUrl(path, { bucket });
|
||||
return c.json({ signedUrl });
|
||||
}
|
||||
|
||||
throw new HTTPException(403);
|
||||
},
|
||||
);
|
||||
14
packages/api/src/routes/webhooks.ts
Normal file
14
packages/api/src/routes/webhooks.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { webhookHandler as paymentsWebhookHandler } from "@repo/payments";
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
|
||||
export const webhooksRouter = new Hono().post(
|
||||
"/webhooks/payments",
|
||||
describeRoute({
|
||||
tags: ["Webhooks"],
|
||||
summary: "Handle payments webhook",
|
||||
}),
|
||||
(c) => {
|
||||
return paymentsWebhookHandler(c.req.raw);
|
||||
},
|
||||
);
|
||||
5
packages/api/tsconfig.json
Normal file
5
packages/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/react-library.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue