first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
3
packages/ai/biome.json
Normal file
3
packages/ai/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
1
packages/ai/client.ts
Normal file
1
packages/ai/client.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "@ai-sdk/react";
|
||||
8
packages/ai/index.ts
Normal file
8
packages/ai/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export const textModel = openai("gpt-4o-mini");
|
||||
export const imageModel = openai("dall-e-3");
|
||||
export const audioModel = openai("whisper-1");
|
||||
|
||||
export * from "ai";
|
||||
export * from "./lib";
|
||||
1
packages/ai/lib/index.ts
Normal file
1
packages/ai/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./prompts";
|
||||
9
packages/ai/lib/prompts.ts
Normal file
9
packages/ai/lib/prompts.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* List product names prompt
|
||||
*
|
||||
* @param {string} topic - The topic to generate product names for.
|
||||
* @return {string} The prompt.
|
||||
*/
|
||||
export const promptListProductNames = (topic: string): string => {
|
||||
return `List me five funny product names that could be used for ${topic}`;
|
||||
};
|
||||
22
packages/ai/package.json
Normal file
22
packages/ai/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.12",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@ai-sdk/react": "^1.2.12",
|
||||
"ai": "^4.3.16",
|
||||
"openai": "^5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/tsconfig": "workspace:*",
|
||||
"@types/react": "19.1.6",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"name": "@repo/ai",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"types": "./**/.tsx",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
5
packages/ai/tsconfig.json
Normal file
5
packages/ai/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/react-library.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
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"]
|
||||
}
|
||||
272
packages/auth/auth.ts
Normal file
272
packages/auth/auth.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { config } from "@repo/config";
|
||||
import {
|
||||
db,
|
||||
getInvitationById,
|
||||
getPurchasesByOrganizationId,
|
||||
getPurchasesByUserId,
|
||||
} from "@repo/database";
|
||||
import { getUserByEmail } from "@repo/database";
|
||||
import type { Locale } from "@repo/i18n";
|
||||
import { logger } from "@repo/logs";
|
||||
import { sendEmail } from "@repo/mail";
|
||||
import { cancelSubscription } from "@repo/payments";
|
||||
import { getBaseUrl } from "@repo/utils";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import {
|
||||
admin,
|
||||
createAuthMiddleware,
|
||||
magicLink,
|
||||
openAPI,
|
||||
organization,
|
||||
twoFactor,
|
||||
username,
|
||||
} from "better-auth/plugins";
|
||||
import { passkey } from "better-auth/plugins/passkey";
|
||||
import { parse as parseCookies } from "cookie";
|
||||
import { updateSeatsInOrganizationSubscription } from "./lib/organization";
|
||||
import { invitationOnlyPlugin } from "./plugins/invitation-only";
|
||||
|
||||
const getLocaleFromRequest = (request?: Request) => {
|
||||
const cookies = parseCookies(request?.headers.get("cookie") ?? "");
|
||||
return (
|
||||
(cookies[config.i18n.localeCookieName] as Locale) ??
|
||||
config.i18n.defaultLocale
|
||||
);
|
||||
};
|
||||
|
||||
const appUrl = getBaseUrl();
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: appUrl,
|
||||
trustedOrigins: [appUrl],
|
||||
appName: config.appName,
|
||||
database: prismaAdapter(db, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: false,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
expiresIn: config.auth.sessionCookieMaxAge,
|
||||
freshAge: 0,
|
||||
},
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["vk", "github"],
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith("/organization/accept-invitation")) {
|
||||
const { invitationId } = ctx.body;
|
||||
|
||||
if (!invitationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invitation = await getInvitationById(invitationId);
|
||||
|
||||
if (!invitation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSeatsInOrganizationSubscription(
|
||||
invitation.organizationId,
|
||||
);
|
||||
} else if (ctx.path.startsWith("/organization/remove-member")) {
|
||||
const { organizationId } = ctx.body;
|
||||
|
||||
if (!organizationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSeatsInOrganizationSubscription(organizationId);
|
||||
}
|
||||
}),
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (
|
||||
ctx.path.startsWith("/delete-user") ||
|
||||
ctx.path.startsWith("/organization/delete")
|
||||
) {
|
||||
const userId = ctx.context.session?.session.userId;
|
||||
const { organizationId } = ctx.body;
|
||||
|
||||
if (userId || organizationId) {
|
||||
const purchases = organizationId
|
||||
? await getPurchasesByOrganizationId(organizationId)
|
||||
: await getPurchasesByUserId(userId!);
|
||||
const subscriptions = purchases.filter(
|
||||
(purchase) =>
|
||||
purchase.type === "SUBSCRIPTION" &&
|
||||
purchase.subscriptionId !== null,
|
||||
);
|
||||
|
||||
if (subscriptions.length > 0) {
|
||||
for (const subscription of subscriptions) {
|
||||
await cancelSubscription(
|
||||
subscription.subscriptionId!,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
onboardingComplete: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
},
|
||||
locale: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
sendChangeEmailVerification: async (
|
||||
{ user: { email, name }, url },
|
||||
request,
|
||||
) => {
|
||||
const locale = getLocaleFromRequest(request);
|
||||
await sendEmail({
|
||||
to: email,
|
||||
templateId: "emailVerification",
|
||||
context: {
|
||||
url,
|
||||
name,
|
||||
},
|
||||
locale,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
// If signup is disabled, the only way to sign up is via an invitation. So in this case we can auto sign in the user, as the email is already verified by the invitation.
|
||||
// If signup is enabled, we can't auto sign in the user, as the email is not verified yet.
|
||||
autoSignIn: !config.auth.enableSignup,
|
||||
requireEmailVerification: config.auth.enableSignup,
|
||||
sendResetPassword: async ({ user, url }, request) => {
|
||||
const locale = getLocaleFromRequest(request);
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
templateId: "forgotPassword",
|
||||
context: {
|
||||
url,
|
||||
name: user.name,
|
||||
},
|
||||
locale,
|
||||
});
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: config.auth.enableSignup,
|
||||
sendVerificationEmail: async (
|
||||
{ user: { email, name }, url },
|
||||
request,
|
||||
) => {
|
||||
const locale = getLocaleFromRequest(request);
|
||||
await sendEmail({
|
||||
to: email,
|
||||
templateId: "emailVerification",
|
||||
context: {
|
||||
url,
|
||||
name,
|
||||
},
|
||||
locale,
|
||||
});
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
vk: {
|
||||
clientId: process.env.VK_CLIENT_ID as string,
|
||||
clientSecret: process.env.VK_CLIENT_SECRET as string,
|
||||
scope: ["email"],
|
||||
},
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
scope: ["user:email"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
username(),
|
||||
admin(),
|
||||
passkey(),
|
||||
magicLink({
|
||||
disableSignUp: true,
|
||||
sendMagicLink: async ({ email, url }, request) => {
|
||||
const locale = getLocaleFromRequest(request);
|
||||
await sendEmail({
|
||||
to: email,
|
||||
templateId: "magicLink",
|
||||
context: {
|
||||
url,
|
||||
},
|
||||
locale,
|
||||
});
|
||||
},
|
||||
}),
|
||||
organization({
|
||||
sendInvitationEmail: async (
|
||||
{ email, id, organization },
|
||||
request,
|
||||
) => {
|
||||
const locale = getLocaleFromRequest(request);
|
||||
const existingUser = await getUserByEmail(email);
|
||||
|
||||
const url = new URL(
|
||||
existingUser ? "/auth/login" : "/auth/signup",
|
||||
getBaseUrl(),
|
||||
);
|
||||
|
||||
url.searchParams.set("invitationId", id);
|
||||
url.searchParams.set("email", email);
|
||||
|
||||
await sendEmail({
|
||||
to: email,
|
||||
templateId: "organizationInvitation",
|
||||
locale,
|
||||
context: {
|
||||
organizationName: organization.name,
|
||||
url: url.toString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
openAPI(),
|
||||
invitationOnlyPlugin(),
|
||||
twoFactor(),
|
||||
],
|
||||
onAPIError: {
|
||||
onError(error, ctx) {
|
||||
logger.error(error, { ctx });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export * from "./lib/organization";
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
|
||||
export type ActiveOrganization = NonNullable<
|
||||
Awaited<ReturnType<typeof auth.api.getFullOrganization>>
|
||||
>;
|
||||
|
||||
export type Organization = typeof auth.$Infer.Organization;
|
||||
|
||||
export type OrganizationMemberRole =
|
||||
ActiveOrganization["members"][number]["role"];
|
||||
|
||||
export type OrganizationInvitationStatus = typeof auth.$Infer.Invitation.status;
|
||||
|
||||
export type OrganizationMetadata = Record<string, unknown> | undefined;
|
||||
3
packages/auth/biome.json
Normal file
3
packages/auth/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
25
packages/auth/client.ts
Normal file
25
packages/auth/client.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
adminClient,
|
||||
inferAdditionalFields,
|
||||
magicLinkClient,
|
||||
organizationClient,
|
||||
passkeyClient,
|
||||
twoFactorClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import type { auth } from ".";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
magicLinkClient(),
|
||||
organizationClient(),
|
||||
adminClient(),
|
||||
passkeyClient(),
|
||||
twoFactorClient(),
|
||||
],
|
||||
});
|
||||
|
||||
export type AuthClientErrorCodes = typeof authClient.$ERROR_CODES & {
|
||||
INVALID_INVITATION: string;
|
||||
};
|
||||
1
packages/auth/index.ts
Normal file
1
packages/auth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./auth";
|
||||
18
packages/auth/lib/helper.ts
Normal file
18
packages/auth/lib/helper.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { ActiveOrganization } from "../auth";
|
||||
|
||||
export function isOrganizationAdmin(
|
||||
organization?: ActiveOrganization | null,
|
||||
user?: {
|
||||
id: string;
|
||||
role?: string | null;
|
||||
} | null,
|
||||
) {
|
||||
const userOrganizationRole = organization?.members.find(
|
||||
(member) => member.userId === user?.id,
|
||||
)?.role;
|
||||
|
||||
return (
|
||||
["owner", "admin"].includes(userOrganizationRole ?? "") ||
|
||||
user?.role === "admin"
|
||||
);
|
||||
}
|
||||
34
packages/auth/lib/organization.ts
Normal file
34
packages/auth/lib/organization.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { getOrganizationWithPurchasesAndMembersCount } from "@repo/database";
|
||||
import { logger } from "@repo/logs";
|
||||
import { setSubscriptionSeats } from "@repo/payments";
|
||||
|
||||
export async function updateSeatsInOrganizationSubscription(
|
||||
organizationId: string,
|
||||
) {
|
||||
const organization =
|
||||
await getOrganizationWithPurchasesAndMembersCount(organizationId);
|
||||
|
||||
if (!organization?.purchases.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSubscription = organization.purchases.find(
|
||||
(purchase) => purchase.type === "SUBSCRIPTION",
|
||||
);
|
||||
|
||||
if (!activeSubscription?.subscriptionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setSubscriptionSeats({
|
||||
id: activeSubscription.subscriptionId,
|
||||
seats: organization.membersCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Could not update seats in organization subscription", {
|
||||
organizationId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
packages/auth/package.json
Normal file
27
packages/auth/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@repo/config": "workspace:*",
|
||||
"@repo/database": "workspace:*",
|
||||
"@repo/i18n": "workspace:*",
|
||||
"@repo/logs": "workspace:*",
|
||||
"@repo/mail": "workspace:*",
|
||||
"@repo/payments": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"better-auth": "1.2.8",
|
||||
"cookie": "^1.0.2",
|
||||
"next": "15.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.30"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"name": "@repo/auth",
|
||||
"scripts": {
|
||||
"migrate": "dotenv -e ../../.env.local -- pnpm dlx @better-auth/cli@latest generate --config ./auth.ts --output ../database/prisma/schema.prisma",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"types": "./**/.tsx",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
39
packages/auth/plugins/invitation-only/index.ts
Normal file
39
packages/auth/plugins/invitation-only/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getPendingInvitationByEmail } from "@repo/database";
|
||||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { createAuthMiddleware } from "better-auth/plugins";
|
||||
|
||||
export const invitationOnlyPlugin = () =>
|
||||
({
|
||||
id: "invitationOnlyPlugin",
|
||||
hooks: {
|
||||
before: [
|
||||
{
|
||||
matcher: (context) =>
|
||||
context.path.startsWith("/sign-up/email"),
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
if (config.auth.enableSignup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email } = ctx.body;
|
||||
|
||||
// check if there is an invitation for the email
|
||||
const hasInvitation =
|
||||
await getPendingInvitationByEmail(email);
|
||||
|
||||
if (!hasInvitation) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
code: "INVALID_INVITATION",
|
||||
message: "No invitation found for this email",
|
||||
});
|
||||
}
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
$ERROR_CODES: {
|
||||
INVALID_INVITATION: "No invitation found for this email",
|
||||
},
|
||||
}) satisfies BetterAuthPlugin;
|
||||
5
packages/auth/tsconfig.json
Normal file
5
packages/auth/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
3
packages/database/biome.json
Normal file
3
packages/database/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
15
packages/database/drizzle/client.ts
Normal file
15
packages/database/drizzle/client.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "./schema/postgres";
|
||||
|
||||
// Check the drizzle documentation for more information on how to connect to your preferred database provider
|
||||
// https://orm.drizzle.team/docs/get-started-postgresql
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL as string;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
export const db = drizzle(databaseUrl, {
|
||||
schema,
|
||||
});
|
||||
9
packages/database/drizzle/drizzle.config.ts
Normal file
9
packages/database/drizzle/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./drizzle/schema/postgres.ts",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL as string,
|
||||
},
|
||||
});
|
||||
4
packages/database/drizzle/index.ts
Normal file
4
packages/database/drizzle/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./client";
|
||||
export * from "./zod";
|
||||
export * from "./queries";
|
||||
export * from "./schema";
|
||||
88
packages/database/drizzle/queries/ai-chats.ts
Normal file
88
packages/database/drizzle/queries/ai-chats.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
import { aiChat } from "../schema/postgres";
|
||||
|
||||
export async function getAiChatsByUserId({
|
||||
limit,
|
||||
offset,
|
||||
userId,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
userId: string;
|
||||
}) {
|
||||
return await db.query.aiChat.findMany({
|
||||
where: (aiChat, { eq }) => eq(aiChat.userId, userId),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAiChatsByOrganizationId({
|
||||
limit,
|
||||
offset,
|
||||
organizationId,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
organizationId: string;
|
||||
}) {
|
||||
return await db.query.aiChat.findMany({
|
||||
where: (aiChat, { eq }) => eq(aiChat.organizationId, organizationId),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAiChatById(id: string) {
|
||||
return db.query.aiChat.findFirst({
|
||||
where: (aiChat, { eq }) => eq(aiChat.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAiChat({
|
||||
organizationId,
|
||||
userId,
|
||||
title,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
userId: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const [{ id }] = await db
|
||||
.insert(aiChat)
|
||||
.values({
|
||||
organizationId,
|
||||
userId,
|
||||
title,
|
||||
})
|
||||
.returning({ id: aiChat.id });
|
||||
|
||||
const createdChat = await getAiChatById(id);
|
||||
|
||||
if (!createdChat) {
|
||||
throw new Error("Failed to create chat");
|
||||
}
|
||||
|
||||
return createdChat;
|
||||
}
|
||||
|
||||
export async function updateAiChat({
|
||||
id,
|
||||
title,
|
||||
messages,
|
||||
}: {
|
||||
id: string;
|
||||
title?: string;
|
||||
messages?: typeof aiChat.$inferInsert.messages;
|
||||
}) {
|
||||
return await db
|
||||
.update(aiChat)
|
||||
.set({ title, messages })
|
||||
.where(eq(aiChat.id, id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
export async function deleteAiChat(id: string) {
|
||||
return await db.delete(aiChat).where(eq(aiChat.id, id));
|
||||
}
|
||||
4
packages/database/drizzle/queries/index.ts
Normal file
4
packages/database/drizzle/queries/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./organizations";
|
||||
export * from "./users";
|
||||
export * from "./ai-chats";
|
||||
export * from "./purchases";
|
||||
105
packages/database/drizzle/queries/organizations.ts
Normal file
105
packages/database/drizzle/queries/organizations.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../client";
|
||||
import { member, organization } from "../schema/postgres";
|
||||
import type { OrganizationUpdateSchema } from "../zod";
|
||||
|
||||
export async function getOrganizations({
|
||||
limit,
|
||||
offset,
|
||||
query,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
query?: string;
|
||||
}) {
|
||||
return db.query.organization.findMany({
|
||||
where: query
|
||||
? (org, { like }) => like(org.name, `%${query}%`)
|
||||
: undefined,
|
||||
limit,
|
||||
offset,
|
||||
extras: {
|
||||
membersCount: db
|
||||
.$count(member, eq(member.organizationId, organization.id))
|
||||
.as("membersCount"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function countAllOrganizations() {
|
||||
return db.$count(organization);
|
||||
}
|
||||
|
||||
export async function getOrganizationById(id: string) {
|
||||
return db.query.organization.findFirst({
|
||||
where: (org, { eq }) => eq(org.id, id),
|
||||
with: {
|
||||
members: true,
|
||||
invitations: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInvitationById(id: string) {
|
||||
return db.query.invitation.findFirst({
|
||||
where: (invitation, { eq }) => eq(invitation.id, id),
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrganizationBySlug(slug: string) {
|
||||
return db.query.organization.findFirst({
|
||||
where: (org, { eq }) => eq(org.slug, slug),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrganizationMembership(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
return db.query.member.findFirst({
|
||||
where: (member, { eq }) =>
|
||||
and(
|
||||
eq(member.organizationId, organizationId),
|
||||
eq(member.userId, userId),
|
||||
),
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrganizationWithPurchasesAndMembersCount(
|
||||
organizationId: string,
|
||||
) {
|
||||
return db.query.organization.findFirst({
|
||||
where: (org, { eq }) => eq(org.id, organizationId),
|
||||
with: {
|
||||
purchases: true,
|
||||
},
|
||||
extras: {
|
||||
membersCount: db
|
||||
.$count(member, eq(member.organizationId, organization.id))
|
||||
.as("membersCount"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPendingInvitationByEmail(email: string) {
|
||||
return db.query.invitation.findFirst({
|
||||
where: (invitation, { eq }) =>
|
||||
and(eq(invitation.email, email), eq(invitation.status, "pending")),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateOrganization(
|
||||
updatedOrganization: z.infer<typeof OrganizationUpdateSchema>,
|
||||
) {
|
||||
return db
|
||||
.update(organization)
|
||||
.set(updatedOrganization)
|
||||
.where(eq(organization.id, updatedOrganization.id));
|
||||
}
|
||||
59
packages/database/drizzle/queries/purchases.ts
Normal file
59
packages/database/drizzle/queries/purchases.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../client";
|
||||
import { purchase } from "../schema/postgres";
|
||||
import type { PurchaseInsertSchema, PurchaseUpdateSchema } from "../zod";
|
||||
|
||||
export async function getPurchasesByOrganizationId(organizationId: string) {
|
||||
return db.query.purchase.findMany({
|
||||
where: (purchase, { eq }) =>
|
||||
eq(purchase.organizationId, organizationId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPurchasesByUserId(userId: string) {
|
||||
return db.query.purchase.findMany({
|
||||
where: (purchase, { eq }) => eq(purchase.userId, userId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPurchaseById(id: string) {
|
||||
return db.query.purchase.findFirst({
|
||||
where: (purchase, { eq }) => eq(purchase.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPurchaseBySubscriptionId(subscriptionId: string) {
|
||||
return db.query.purchase.findFirst({
|
||||
where: (purchase, { eq }) =>
|
||||
eq(purchase.subscriptionId, subscriptionId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPurchase(
|
||||
insertedPurchase: z.infer<typeof PurchaseInsertSchema>,
|
||||
) {
|
||||
const [{ id }] = await db
|
||||
.insert(purchase)
|
||||
.values(insertedPurchase)
|
||||
.returning({ id: purchase.id });
|
||||
|
||||
return getPurchaseById(id);
|
||||
}
|
||||
|
||||
export async function updatePurchase(
|
||||
updatedPurchase: z.infer<typeof PurchaseUpdateSchema>,
|
||||
) {
|
||||
const [{ id }] = await db
|
||||
.update(purchase)
|
||||
.set(updatedPurchase)
|
||||
.returning({ id: purchase.id });
|
||||
|
||||
return getPurchaseById(id);
|
||||
}
|
||||
|
||||
export async function deletePurchaseBySubscriptionId(subscriptionId: string) {
|
||||
await db
|
||||
.delete(purchase)
|
||||
.where(eq(purchase.subscriptionId, subscriptionId));
|
||||
}
|
||||
112
packages/database/drizzle/queries/users.ts
Normal file
112
packages/database/drizzle/queries/users.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db } from "../client";
|
||||
import { account, user } from "../schema/postgres";
|
||||
import type { UserUpdateSchema } from "../zod";
|
||||
|
||||
export async function getUsers({
|
||||
limit,
|
||||
offset,
|
||||
query,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
query?: string;
|
||||
}) {
|
||||
return await db.query.user.findMany({
|
||||
where: (user, { like }) => like(user.name, `%${query}%`),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
export async function countAllUsers() {
|
||||
return db.$count(user);
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
return await db.query.user.findFirst({
|
||||
where: (user, { eq }) => eq(user.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
return await db.query.user.findFirst({
|
||||
where: (user, { eq }) => eq(user.email, email),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUser({
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
emailVerified,
|
||||
onboardingComplete,
|
||||
}: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "user";
|
||||
emailVerified: boolean;
|
||||
onboardingComplete: boolean;
|
||||
}) {
|
||||
const [{ id }] = await db
|
||||
.insert(user)
|
||||
.values({
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
emailVerified,
|
||||
onboardingComplete,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
const newUser = await getUserById(id);
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
export async function getAccountById(id: string) {
|
||||
return await db.query.account.findFirst({
|
||||
where: (account, { eq }) => eq(account.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUserAccount({
|
||||
userId,
|
||||
providerId,
|
||||
accountId,
|
||||
hashedPassword,
|
||||
}: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
accountId: string;
|
||||
hashedPassword?: string;
|
||||
}) {
|
||||
const [{ id }] = await db
|
||||
.insert(account)
|
||||
.values({
|
||||
userId,
|
||||
accountId,
|
||||
providerId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
password: hashedPassword,
|
||||
})
|
||||
.returning({
|
||||
id: account.id,
|
||||
});
|
||||
|
||||
const newAccount = await getAccountById(id);
|
||||
|
||||
return newAccount;
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
updatedUser: z.infer<typeof UserUpdateSchema>,
|
||||
) {
|
||||
return db.update(user).set(updatedUser).where(eq(user.id, updatedUser.id));
|
||||
}
|
||||
1
packages/database/drizzle/schema/index.ts
Normal file
1
packages/database/drizzle/schema/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./sqlite";
|
||||
226
packages/database/drizzle/schema/mysql.ts
Normal file
226
packages/database/drizzle/schema/mysql.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { createId as cuid } from "@paralleldrive/cuid2";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
int,
|
||||
json,
|
||||
mysqlEnum,
|
||||
mysqlTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
// Enums
|
||||
export const purchaseTypeEnum = mysqlEnum("PurchaseType", [
|
||||
"SUBSCRIPTION",
|
||||
"ONE_TIME",
|
||||
]);
|
||||
|
||||
// Tables
|
||||
export const user = mysqlTable("user", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
username: text("username").unique(),
|
||||
role: text("role"),
|
||||
banned: boolean("banned"),
|
||||
banReason: text("banReason"),
|
||||
banExpires: timestamp("banExpires"),
|
||||
onboardingComplete: boolean("onboardingComplete").default(false).notNull(),
|
||||
paymentsCustomerId: text("paymentsCustomerId"),
|
||||
locale: text("locale"),
|
||||
});
|
||||
|
||||
export const session = mysqlTable(
|
||||
"session",
|
||||
{
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
ipAddress: text("ipAddress"),
|
||||
userAgent: text("userAgent"),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonatedBy"),
|
||||
activeOrganizationId: text("activeOrganizationId"),
|
||||
token: text("token").notNull(),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
updatedAt: timestamp("updatedAt").notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("session_token_idx").on(table.token)],
|
||||
);
|
||||
|
||||
export const account = mysqlTable("account", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
accountId: text("accountId").notNull(),
|
||||
providerId: text("providerId").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("accessToken"),
|
||||
refreshToken: text("refreshToken"),
|
||||
idToken: text("idToken"),
|
||||
expiresAt: timestamp("expiresAt"),
|
||||
password: text("password"),
|
||||
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
|
||||
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
|
||||
scope: text("scope"),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
updatedAt: timestamp("updatedAt").notNull(),
|
||||
});
|
||||
|
||||
export const verification = mysqlTable("verification", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
createdAt: timestamp("createdAt"),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
});
|
||||
|
||||
export const passkey = mysqlTable("passkey", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
credentialID: text("credentialID").notNull(),
|
||||
counter: int("counter").notNull(),
|
||||
deviceType: text("deviceType").notNull(),
|
||||
backedUp: boolean("backedUp").notNull(),
|
||||
transports: text("transports"),
|
||||
createdAt: timestamp("createdAt"),
|
||||
});
|
||||
|
||||
export const twoFactor = mysqlTable("twoFactor", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backupCodes").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const organization = mysqlTable(
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug"),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
metadata: text("metadata"),
|
||||
paymentsCustomerId: text("paymentsCustomerId"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_idx").on(table.slug)],
|
||||
);
|
||||
|
||||
export const member = mysqlTable(
|
||||
"member",
|
||||
{
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("member_user_org_idx").on(
|
||||
table.userId,
|
||||
table.organizationId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = mysqlTable("invitation", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").notNull(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
inviterId: text("inviterId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const purchase = mysqlTable("purchase", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId").references(() => user.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
type: purchaseTypeEnum.notNull(),
|
||||
customerId: text("customerId").notNull(),
|
||||
subscriptionId: text("subscriptionId").unique(),
|
||||
productId: text("productId").notNull(),
|
||||
status: text("status"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
});
|
||||
|
||||
export const aiChat = mysqlTable("aiChat", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
messages: json("messages"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
passkeys: many(passkey),
|
||||
invitations: many(invitation),
|
||||
purchases: many(purchase),
|
||||
memberships: many(member),
|
||||
aiChats: many(aiChat),
|
||||
twoFactors: many(twoFactor),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
purchases: many(purchase),
|
||||
aiChats: many(aiChat),
|
||||
}));
|
||||
235
packages/database/drizzle/schema/postgres.ts
Normal file
235
packages/database/drizzle/schema/postgres.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { createId as cuid } from "@paralleldrive/cuid2";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// Enums
|
||||
export const purchaseTypeEnum = pgEnum("PurchaseType", [
|
||||
"SUBSCRIPTION",
|
||||
"ONE_TIME",
|
||||
]);
|
||||
|
||||
// Tables
|
||||
export const user = pgTable("user", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
username: text("username").unique(),
|
||||
role: text("role"),
|
||||
banned: boolean("banned"),
|
||||
banReason: text("banReason"),
|
||||
banExpires: timestamp("banExpires"),
|
||||
onboardingComplete: boolean("onboardingComplete").default(false).notNull(),
|
||||
paymentsCustomerId: text("paymentsCustomerId"),
|
||||
locale: text("locale"),
|
||||
});
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
ipAddress: text("ipAddress"),
|
||||
userAgent: text("userAgent"),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonatedBy"),
|
||||
activeOrganizationId: text("activeOrganizationId"),
|
||||
token: text("token").notNull(),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
updatedAt: timestamp("updatedAt").notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("session_token_idx").on(table.token)],
|
||||
);
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
accountId: text("accountId").notNull(),
|
||||
providerId: text("providerId").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("accessToken"),
|
||||
refreshToken: text("refreshToken"),
|
||||
idToken: text("idToken"),
|
||||
expiresAt: timestamp("expiresAt"),
|
||||
password: text("password"),
|
||||
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
|
||||
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
|
||||
scope: text("scope"),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
updatedAt: timestamp("updatedAt").notNull(),
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
createdAt: timestamp("createdAt"),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
});
|
||||
|
||||
export const passkey = pgTable("passkey", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
credentialID: text("credentialID").notNull(),
|
||||
counter: integer("counter").notNull(),
|
||||
deviceType: text("deviceType").notNull(),
|
||||
backedUp: boolean("backedUp").notNull(),
|
||||
transports: text("transports"),
|
||||
createdAt: timestamp("createdAt"),
|
||||
});
|
||||
|
||||
export const twoFactor = pgTable("twoFactor", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backupCodes").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const organization = pgTable(
|
||||
"organization",
|
||||
{
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug"),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
metadata: text("metadata"),
|
||||
paymentsCustomerId: text("paymentsCustomerId"),
|
||||
},
|
||||
|
||||
(table) => [uniqueIndex("organization_slug_idx").on(table.slug)],
|
||||
);
|
||||
|
||||
export const member = pgTable(
|
||||
"member",
|
||||
{
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("member_user_org_idx").on(
|
||||
table.userId,
|
||||
table.organizationId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = pgTable("invitation", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").notNull(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
inviterId: text("inviterId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const purchase = pgTable("purchase", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId").references(() => user.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
type: purchaseTypeEnum("type").notNull(),
|
||||
customerId: text("customerId").notNull(),
|
||||
subscriptionId: text("subscriptionId").unique(),
|
||||
productId: text("productId").notNull(),
|
||||
status: text("status"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
});
|
||||
|
||||
export const aiChat = pgTable("aiChat", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
messages:
|
||||
json("messages").$type<
|
||||
{
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}[]
|
||||
>(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
passkeys: many(passkey),
|
||||
invitations: many(invitation),
|
||||
purchases: many(purchase),
|
||||
memberships: many(member),
|
||||
aiChats: many(aiChat),
|
||||
twoFactors: many(twoFactor),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
purchases: many(purchase),
|
||||
aiChats: many(aiChat),
|
||||
}));
|
||||
235
packages/database/drizzle/schema/sqlite.ts
Normal file
235
packages/database/drizzle/schema/sqlite.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { createId as cuid } from "@paralleldrive/cuid2";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
blob,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
// Tables
|
||||
export const user = sqliteTable("user", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("emailVerified", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
image: text("image"),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: integer("updatedAt", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
username: text("username").unique(),
|
||||
role: text("role"),
|
||||
banned: integer("banned", { mode: "boolean" }),
|
||||
banReason: text("banReason"),
|
||||
banExpires: integer("banExpires", { mode: "timestamp" }),
|
||||
onboardingComplete: integer("onboardingComplete", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
paymentsCustomerId: text("paymentsCustomerId"),
|
||||
locale: text("locale"),
|
||||
});
|
||||
|
||||
export const session = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
expiresAt: integer("expiresAt", { mode: "timestamp" }).notNull(),
|
||||
ipAddress: text("ipAddress"),
|
||||
userAgent: text("userAgent"),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonatedBy"),
|
||||
activeOrganizationId: text("activeOrganizationId"),
|
||||
token: text("token").notNull(),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("session_token_idx").on(table.token)],
|
||||
);
|
||||
|
||||
export const account = sqliteTable("account", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
accountId: text("accountId").notNull(),
|
||||
providerId: text("providerId").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("accessToken"),
|
||||
refreshToken: text("refreshToken"),
|
||||
idToken: text("idToken"),
|
||||
expiresAt: integer("expiresAt", { mode: "timestamp" }),
|
||||
password: text("password"),
|
||||
accessTokenExpiresAt: integer("accessTokenExpiresAt", {
|
||||
mode: "timestamp",
|
||||
}),
|
||||
refreshTokenExpiresAt: integer("refreshTokenExpiresAt", {
|
||||
mode: "timestamp",
|
||||
}),
|
||||
scope: text("scope"),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export const verification = sqliteTable("verification", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expiresAt", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" }),
|
||||
updatedAt: integer("updatedAt", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
export const passkey = sqliteTable("passkey", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
credentialID: text("credentialID").notNull(),
|
||||
counter: integer("counter").notNull(),
|
||||
deviceType: text("deviceType").notNull(),
|
||||
backedUp: integer("backedUp", { mode: "boolean" }).notNull(),
|
||||
transports: text("transports"),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
export const twoFactor = sqliteTable("twoFactor", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backupCodes").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const organization = sqliteTable(
|
||||
"organization",
|
||||
{
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug"),
|
||||
logo: text("logo"),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
|
||||
metadata: text("metadata"),
|
||||
paymentsCustomerId: text("paymentsCustomerId"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_idx").on(table.slug)],
|
||||
);
|
||||
|
||||
export const member = sqliteTable(
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("member_user_org_idx").on(
|
||||
table.userId,
|
||||
table.organizationId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = sqliteTable("invitation", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").notNull(),
|
||||
expiresAt: integer("expiresAt", { mode: "timestamp" }).notNull(),
|
||||
inviterId: text("inviterId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const purchase = sqliteTable("purchase", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId").references(() => user.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
type: text({ enum: ["SUBSCRIPTION", "ONE_TIME"] }).notNull(),
|
||||
customerId: text("customerId").notNull(),
|
||||
subscriptionId: text("subscriptionId").unique(),
|
||||
productId: text("productId").notNull(),
|
||||
status: text("status"),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: integer("updatedAt", { mode: "timestamp" }).default(
|
||||
sql`CURRENT_TIMESTAMP`,
|
||||
),
|
||||
});
|
||||
|
||||
export const aiChat = sqliteTable("aiChat", {
|
||||
id: text("id")
|
||||
.$defaultFn(() => cuid())
|
||||
.primaryKey(),
|
||||
organizationId: text("organizationId").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
messages: blob("messages", { mode: "json" }),
|
||||
createdAt: integer("createdAt", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: integer("updatedAt", { mode: "timestamp" }).default(
|
||||
sql`CURRENT_TIMESTAMP`,
|
||||
),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
passkeys: many(passkey),
|
||||
invitations: many(invitation),
|
||||
purchases: many(purchase),
|
||||
memberships: many(member),
|
||||
aiChats: many(aiChat),
|
||||
twoFactors: many(twoFactor),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
purchases: many(purchase),
|
||||
aiChats: many(aiChat),
|
||||
}));
|
||||
40
packages/database/drizzle/zod.ts
Normal file
40
packages/database/drizzle/zod.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
createInsertSchema,
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
} from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
account,
|
||||
aiChat,
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
passkey,
|
||||
purchase,
|
||||
session,
|
||||
user,
|
||||
verification,
|
||||
} from "./schema";
|
||||
|
||||
export const AiChatSchema = createSelectSchema(aiChat);
|
||||
export const UserSchema = createSelectSchema(user);
|
||||
export const UserUpdateSchema = createUpdateSchema(user, {
|
||||
id: z.string(),
|
||||
});
|
||||
export const OrganizationSchema = createSelectSchema(organization);
|
||||
export const OrganizationUpdateSchema = createUpdateSchema(organization, {
|
||||
id: z.string(),
|
||||
});
|
||||
export const MemberSchema = createSelectSchema(member);
|
||||
export const InvitationSchema = createSelectSchema(invitation);
|
||||
export const PurchaseSchema = createSelectSchema(purchase);
|
||||
export type Purchase = typeof purchase.$inferSelect;
|
||||
export const PurchaseInsertSchema = createInsertSchema(purchase);
|
||||
export const PurchaseUpdateSchema = createUpdateSchema(purchase, {
|
||||
id: z.string(),
|
||||
});
|
||||
export const SessionSchema = createSelectSchema(session);
|
||||
export const AccountSchema = createSelectSchema(account);
|
||||
export const VerificationSchema = createSelectSchema(verification);
|
||||
export const PasskeySchema = createSelectSchema(passkey);
|
||||
1
packages/database/index.ts
Normal file
1
packages/database/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./prisma";
|
||||
32
packages/database/package.json
Normal file
32
packages/database/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@prisma/client": "^6.9.0",
|
||||
"@repo/config": "workspace:*",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"drizzle-zod": "^0.8.2",
|
||||
"pg": "^8.16.0",
|
||||
"zod": "^3.25.55"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/tsconfig": "workspace:*",
|
||||
"@types/node": "22.15.30",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"prisma": "^6.9.0",
|
||||
"prisma-json-types-generator": "^3.4.2",
|
||||
"zod-prisma-types": "^3.2.4"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"name": "@repo/database",
|
||||
"scripts": {
|
||||
"generate": "prisma generate --no-hints --schema=./prisma/schema.prisma",
|
||||
"push": "dotenv -c -e ../../.env -- prisma db push --skip-generate --schema=./prisma/schema.prisma",
|
||||
"migrate": "dotenv -c -e ../../.env -- prisma migrate dev --schema=./prisma/schema.prisma",
|
||||
"studio": "dotenv -c -e ../../.env -- prisma studio --schema=./prisma/schema.prisma",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"types": "./**/.tsx",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
18
packages/database/prisma/client.ts
Normal file
18
packages/database/prisma/client.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient();
|
||||
};
|
||||
|
||||
declare global {
|
||||
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noRedeclare: <explanation>
|
||||
const prisma = globalThis.prisma ?? prismaClientSingleton();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.prisma = prisma;
|
||||
}
|
||||
|
||||
export { prisma as db };
|
||||
7
packages/database/prisma/index.ts
Normal file
7
packages/database/prisma/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from "./client";
|
||||
export * from "./zod";
|
||||
export * from "./queries";
|
||||
|
||||
declare global {
|
||||
namespace PrismaJson {}
|
||||
}
|
||||
92
packages/database/prisma/queries/ai-chats.ts
Normal file
92
packages/database/prisma/queries/ai-chats.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { db } from "../client";
|
||||
import type { AiChat } from "../zod";
|
||||
|
||||
export async function getAiChatsByUserId({
|
||||
limit,
|
||||
offset,
|
||||
userId,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
userId: string;
|
||||
}) {
|
||||
return await db.aiChat.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAiChatsByOrganizationId({
|
||||
limit,
|
||||
offset,
|
||||
organizationId,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
organizationId: string;
|
||||
}) {
|
||||
return await db.aiChat.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAiChatById(id: string) {
|
||||
return await db.aiChat.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAiChat({
|
||||
organizationId,
|
||||
userId,
|
||||
title,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
userId: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return await db.aiChat.create({
|
||||
data: {
|
||||
organizationId,
|
||||
userId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAiChat({
|
||||
id,
|
||||
title,
|
||||
messages,
|
||||
}: {
|
||||
id: string;
|
||||
title?: string;
|
||||
messages?: AiChat["messages"];
|
||||
}) {
|
||||
return await db.aiChat.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
messages,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAiChat(id: string) {
|
||||
return await db.aiChat.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
4
packages/database/prisma/queries/index.ts
Normal file
4
packages/database/prisma/queries/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./ai-chats";
|
||||
export * from "./organizations";
|
||||
export * from "./purchases";
|
||||
export * from "./users";
|
||||
128
packages/database/prisma/queries/organizations.ts
Normal file
128
packages/database/prisma/queries/organizations.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { z } from "zod";
|
||||
import { db } from "../client";
|
||||
import type { OrganizationUpdateInputSchema } from "../zod";
|
||||
|
||||
export async function getOrganizations({
|
||||
limit,
|
||||
offset,
|
||||
query,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
query?: string;
|
||||
}) {
|
||||
return db.organization
|
||||
.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
})
|
||||
.then((res) =>
|
||||
res.map((org) => ({
|
||||
...org,
|
||||
membersCount: org._count.members,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function countAllOrganizations() {
|
||||
return db.organization.count();
|
||||
}
|
||||
|
||||
export async function getOrganizationById(id: string) {
|
||||
return db.organization.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
members: true,
|
||||
invitations: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInvitationById(id: string) {
|
||||
return db.invitation.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrganizationBySlug(slug: string) {
|
||||
return db.organization.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrganizationMembership(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
return db.member.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrganizationWithPurchasesAndMembersCount(
|
||||
organizationId: string,
|
||||
) {
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
include: {
|
||||
purchases: true,
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organization
|
||||
? {
|
||||
...organization,
|
||||
membersCount: organization._count.members,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function getPendingInvitationByEmail(email: string) {
|
||||
return db.invitation.findFirst({
|
||||
where: {
|
||||
email,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateOrganization(
|
||||
organization: z.infer<typeof OrganizationUpdateInputSchema> & {
|
||||
id: string;
|
||||
},
|
||||
) {
|
||||
return db.organization.update({
|
||||
where: {
|
||||
id: organization.id,
|
||||
},
|
||||
data: organization,
|
||||
});
|
||||
}
|
||||
69
packages/database/prisma/queries/purchases.ts
Normal file
69
packages/database/prisma/queries/purchases.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { z } from "zod";
|
||||
import { db } from "../client";
|
||||
import type {
|
||||
PurchaseUncheckedCreateInputSchema,
|
||||
PurchaseUncheckedUpdateInputSchema,
|
||||
} from "../zod";
|
||||
|
||||
export async function getPurchaseById(id: string) {
|
||||
return db.purchase.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPurchasesByOrganizationId(organizationId: string) {
|
||||
return db.purchase.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPurchasesByUserId(userId: string) {
|
||||
return db.purchase.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPurchaseBySubscriptionId(subscriptionId: string) {
|
||||
return db.purchase.findFirst({
|
||||
where: {
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPurchase(
|
||||
purchase: z.infer<typeof PurchaseUncheckedCreateInputSchema>,
|
||||
) {
|
||||
const created = await db.purchase.create({
|
||||
data: purchase,
|
||||
});
|
||||
|
||||
return getPurchaseById(created.id);
|
||||
}
|
||||
|
||||
export async function updatePurchase(
|
||||
purchase: z.infer<typeof PurchaseUncheckedUpdateInputSchema> & {
|
||||
id: string;
|
||||
},
|
||||
) {
|
||||
const updated = await db.purchase.update({
|
||||
where: {
|
||||
id: purchase.id,
|
||||
},
|
||||
data: purchase,
|
||||
});
|
||||
|
||||
return getPurchaseById(updated.id);
|
||||
}
|
||||
|
||||
export async function deletePurchaseBySubscriptionId(subscriptionId: string) {
|
||||
await db.purchase.delete({
|
||||
where: {
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
113
packages/database/prisma/queries/users.ts
Normal file
113
packages/database/prisma/queries/users.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { z } from "zod";
|
||||
import { db } from "../client";
|
||||
import type { UserUncheckedUpdateInputSchema } from "../zod";
|
||||
|
||||
export async function getUsers({
|
||||
limit,
|
||||
offset,
|
||||
query,
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
query?: string;
|
||||
}) {
|
||||
return await db.user.findMany({
|
||||
where: query
|
||||
? {
|
||||
name: {
|
||||
contains: query,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
export async function countAllUsers() {
|
||||
return await db.user.count();
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
return await db.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
return await db.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUser({
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
emailVerified,
|
||||
onboardingComplete,
|
||||
}: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "user";
|
||||
emailVerified: boolean;
|
||||
onboardingComplete: boolean;
|
||||
}) {
|
||||
return await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
emailVerified,
|
||||
onboardingComplete,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAccountById(id: string) {
|
||||
return await db.account.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUserAccount({
|
||||
userId,
|
||||
providerId,
|
||||
accountId,
|
||||
hashedPassword,
|
||||
}: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
accountId: string;
|
||||
hashedPassword?: string;
|
||||
}) {
|
||||
return await db.account.create({
|
||||
data: {
|
||||
userId,
|
||||
accountId,
|
||||
providerId,
|
||||
password: hashedPassword,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
user: z.infer<typeof UserUncheckedUpdateInputSchema> & { id: string },
|
||||
) {
|
||||
return await db.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: user,
|
||||
});
|
||||
}
|
||||
212
packages/database/prisma/schema.prisma
Normal file
212
packages/database/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
generator zod {
|
||||
provider = "zod-prisma-types"
|
||||
output = "./zod"
|
||||
createInputTypes = true
|
||||
addIncludeType = false
|
||||
addSelectType = false
|
||||
}
|
||||
|
||||
generator json {
|
||||
provider = "prisma-json-types-generator"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String
|
||||
emailVerified Boolean
|
||||
image String?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
username String?
|
||||
role String?
|
||||
banned Boolean?
|
||||
banReason String?
|
||||
banExpires DateTime?
|
||||
onboardingComplete Boolean @default(false)
|
||||
paymentsCustomerId String?
|
||||
locale String?
|
||||
twoFactorEnabled Boolean?
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
passkeys Passkey[]
|
||||
invitations Invitation[]
|
||||
purchases Purchase[]
|
||||
members Member[]
|
||||
twofactors TwoFactor[]
|
||||
aiChats AiChat[]
|
||||
|
||||
@@unique([email])
|
||||
@@unique([username])
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
expiresAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
impersonatedBy String?
|
||||
|
||||
activeOrganizationId String?
|
||||
|
||||
token String
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
|
||||
@@unique([token])
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
providerId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String? @db.Text
|
||||
refreshToken String? @db.Text
|
||||
idToken String? @db.Text
|
||||
expiresAt DateTime?
|
||||
password String?
|
||||
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id @default(cuid())
|
||||
identifier String
|
||||
value String @db.Text
|
||||
expiresAt DateTime
|
||||
|
||||
createdAt DateTime?
|
||||
updatedAt DateTime?
|
||||
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
model Passkey {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
publicKey String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
credentialID String
|
||||
counter Int
|
||||
deviceType String
|
||||
backedUp Boolean
|
||||
transports String?
|
||||
createdAt DateTime?
|
||||
|
||||
@@map("passkey")
|
||||
}
|
||||
|
||||
model TwoFactor {
|
||||
id String @id @default(cuid())
|
||||
secret String
|
||||
backupCodes String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("twoFactor")
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String?
|
||||
logo String?
|
||||
createdAt DateTime
|
||||
metadata String?
|
||||
paymentsCustomerId String?
|
||||
members Member[]
|
||||
invitations Invitation[]
|
||||
purchases Purchase[]
|
||||
aiChats AiChat[]
|
||||
|
||||
@@unique([slug])
|
||||
@@map("organization")
|
||||
}
|
||||
|
||||
model Member {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role String
|
||||
createdAt DateTime
|
||||
|
||||
@@unique([organizationId, userId])
|
||||
@@map("member")
|
||||
}
|
||||
|
||||
model Invitation {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
email String
|
||||
role String?
|
||||
status String
|
||||
expiresAt DateTime
|
||||
inviterId String
|
||||
user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("invitation")
|
||||
}
|
||||
|
||||
enum PurchaseType {
|
||||
SUBSCRIPTION
|
||||
ONE_TIME
|
||||
}
|
||||
|
||||
model Purchase {
|
||||
id String @id @default(cuid())
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
type PurchaseType
|
||||
customerId String
|
||||
subscriptionId String? @unique
|
||||
productId String
|
||||
status String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([subscriptionId])
|
||||
@@map("purchase")
|
||||
}
|
||||
|
||||
model AiChat {
|
||||
id String @id @default(cuid())
|
||||
organizationId String?
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
title String?
|
||||
/// [Array<{role: "user" | "assistant"; content: string;}>]
|
||||
messages Json @default("[]") /// @zod.custom.use(z.array(z.object({ role: z.enum(['user', 'assistant']), content: z.string() })))
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("ai_chat")
|
||||
}
|
||||
7633
packages/database/prisma/zod/index.ts
Normal file
7633
packages/database/prisma/zod/index.ts
Normal file
File diff suppressed because it is too large
Load diff
5
packages/database/tsconfig.json
Normal file
5
packages/database/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules", "prisma/zod/**/*.ts"]
|
||||
}
|
||||
3
packages/i18n/biome.json
Normal file
3
packages/i18n/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
2
packages/i18n/index.ts
Normal file
2
packages/i18n/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./lib/messages";
|
||||
export * from "./types";
|
||||
18
packages/i18n/lib/messages.ts
Normal file
18
packages/i18n/lib/messages.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { config } from "@repo/config";
|
||||
import deepmerge from "deepmerge";
|
||||
import type { Messages } from "../types";
|
||||
|
||||
export const importLocale = async (locale: string): Promise<Messages> => {
|
||||
return (await import(`../translations/${locale}.json`)).default as Messages;
|
||||
};
|
||||
|
||||
export const getMessagesForLocale = async (
|
||||
locale: string,
|
||||
): Promise<Messages> => {
|
||||
const localeMessages = await importLocale(locale);
|
||||
if (locale === config.i18n.defaultLocale) {
|
||||
return localeMessages;
|
||||
}
|
||||
const defaultLocaleMessages = await importLocale(config.i18n.defaultLocale);
|
||||
return deepmerge(defaultLocaleMessages, localeMessages);
|
||||
};
|
||||
16
packages/i18n/package.json
Normal file
16
packages/i18n/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@repo/config": "workspace:*",
|
||||
"deepmerge": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.30"
|
||||
},
|
||||
"name": "@repo/i18n",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"version": "0.0.0"
|
||||
}
|
||||
826
packages/i18n/translations/de.json
Normal file
826
packages/i18n/translations/de.json
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
{
|
||||
"admin": {
|
||||
"menu": {
|
||||
"organizations": "Organisationen",
|
||||
"users": "Benutzer"
|
||||
},
|
||||
"organizations": {
|
||||
"backToList": "Zurück zu Organisationen",
|
||||
"confirmDelete": {
|
||||
"confirm": "Löschen",
|
||||
"message": "Bist du sicher, dass du diese Organisation löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"title": "Organisation löschen"
|
||||
},
|
||||
"create": "Erstellen",
|
||||
"delete": "Löschen",
|
||||
"deleteOrganization": {
|
||||
"deleted": "Die Organisation wurde erfolgreich gelöscht!",
|
||||
"deleting": "Löschung der Organisation...",
|
||||
"notDeleted": "Organisation konnte nicht gelöscht werden. Bitte versuche es erneut."
|
||||
},
|
||||
"edit": "bearbeiten",
|
||||
"form": {
|
||||
"createTitle": "Erstellen eine Organisation",
|
||||
"name": "Name der Organisation",
|
||||
"notifications": {
|
||||
"error": "Die Organisation konnte nicht gespeichert werden. Bitte versuche es später noch einmal.",
|
||||
"success": "Die Organisation wurde erfolgreich gespeichert."
|
||||
},
|
||||
"save": "Speichern",
|
||||
"updateTitle": "Organisation bearbeiten"
|
||||
},
|
||||
"loading": "Organisationen laden...",
|
||||
"membersCount": "{count} {count, plural, one {Mitglied} other {Mitglieder}}",
|
||||
"search": "Suche nach einer Organisation...",
|
||||
"title": "Verwalten von Organisationen"
|
||||
},
|
||||
"title": "Administration",
|
||||
"users": {
|
||||
"confirmDelete": {
|
||||
"confirm": "Löschen",
|
||||
"message": "Bist du sicher, dass du diesen Benutzer löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"title": "Benutzer löschen"
|
||||
},
|
||||
"delete": "Löschen",
|
||||
"deleteUser": {
|
||||
"deleted": "Der Benutzer wurde erfolgreich gelöscht!",
|
||||
"deleting": "Benutzer wird gelöscht...",
|
||||
"notDeleted": "Der Benutzer konnte nicht gelöscht werden. \nBitte versuche es erneut."
|
||||
},
|
||||
"emailVerified": {
|
||||
"verified": "Email verifiziert",
|
||||
"waiting": "E-Mail wartet auf Verifizierung"
|
||||
},
|
||||
"impersonate": "Imitieren",
|
||||
"impersonation": {
|
||||
"impersonating": "Ich gebe mich als {name} aus..."
|
||||
},
|
||||
"loading": "Benutzer werden geladen...",
|
||||
"resendVerificationMail": {
|
||||
"error": "Bestätigungsmail konnte nicht erneut gesendet werden. \nBitte versuche es erneut.",
|
||||
"submitting": "Bestätigungsmail wird erneut gesendet...",
|
||||
"success": "Bestätigungsmail wurde gesendet.",
|
||||
"title": "Bestätigungsmail erneut senden"
|
||||
},
|
||||
"search": "Nach Name oder E-Mail suchen...",
|
||||
"title": "Benutzer verwalten",
|
||||
"assignAdminRole": "Administration-Rolle zuweisen",
|
||||
"removeAdminRole": "Adminstrator-Rolle entfernen"
|
||||
},
|
||||
"description": "Verwalte deine Anwendung."
|
||||
},
|
||||
"app": {
|
||||
"menu": {
|
||||
"accountSettings": "Kontoeinstellungen",
|
||||
"admin": "Administration",
|
||||
"aiChatbot": "AI Chatbot",
|
||||
"organizationSettings": "Organisationseinstellungen",
|
||||
"start": "Start"
|
||||
},
|
||||
"userMenu": {
|
||||
"accountSettings": "Kontoeinstellungen",
|
||||
"colorMode": "Farbmodus",
|
||||
"documentation": "Dokumentation",
|
||||
"home": "Startseite",
|
||||
"logout": "Abmelden"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"credentialAccountNotFound": "Konto nicht gefunden.",
|
||||
"emailCanNotBeUpdated": "E-Mail konnte nicht aktualisiert werden.",
|
||||
"emailNotVerified": "Bitte verifiziere zunächst deine E-Mail, bevor du dich anmeldest.",
|
||||
"failedToCreateSession": "Eine Sitzung konnte nicht erstellt werden.",
|
||||
"failedToCreateUser": "Benutzer konnte nicht erstellt werden.",
|
||||
"failedToGetSession": "Session konnte nicht geladen werden.",
|
||||
"failedToGetUserInfo": "Benutzerinformationen konnten nicht geladen werden.",
|
||||
"failedToUpdateUser": "Benutzer konnte nicht aktualisiert werden.",
|
||||
"idTokenNotSupported": "ID-Token wird nicht unterstützt.",
|
||||
"invalidEmail": "Die eingegebene E-Mail ist ungültig.",
|
||||
"invalidEmailOrPassword": "Die von dir eingegebenen Anmeldedaten sind ungültig. Bitte überprüfe sie und versuche es erneut.",
|
||||
"invalidInvitation": "Die Einladung ist ungültig oder abgelaufen.",
|
||||
"invalidPassword": "Das eingegebene Passwort ist falsch.",
|
||||
"invalidToken": "Das von dir eingegebene Token ist ungültig oder abgelaufen.",
|
||||
"passwordTooLong": "Das Passwort ist zu lang.",
|
||||
"passwordTooShort": "Das Passwort ist zu kurz.",
|
||||
"providerNotFound": "Dieser Anbieter wird nicht unterstützt.",
|
||||
"socialAccountAlreadyLinked": "Dieses Konto ist bereits mit einem Benutzer verknüpft.",
|
||||
"unknown": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"userAlreadyExists": "Dieser Benutzer existiert bereits.",
|
||||
"userEmailNotFound": "E-Mail nicht gefunden.",
|
||||
"userNotFound": "Dieser Benutzer existiert nicht",
|
||||
"sessionExpired": "Die Sitzung ist abgelaufen.",
|
||||
"accountNotFound": "Konto nicht gefunden",
|
||||
"failedToUnlinkLastAccount": "Konto kann nicht entkoppelt werden"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"backToSignin": "Zurück zum Login",
|
||||
"email": "E-Mail",
|
||||
"hints": {
|
||||
"linkNotSent": {
|
||||
"message": "Es tut uns leid, aber wir konnten keinen Link zum Zurücksetzen des Passworts senden. \nBitte versuche es später noch einmal.",
|
||||
"title": "Link nicht gesendet"
|
||||
},
|
||||
"linkSent": {
|
||||
"message": "Wir haben dir einen Link zum Fortfahren gesendet. \nBitte überprüfe deinen Posteingang.",
|
||||
"title": "Link gesendet"
|
||||
}
|
||||
},
|
||||
"message": "Bitte gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen deines Passworts.",
|
||||
"submit": "Link senden",
|
||||
"title": "Hast du dein Passwort vergessen?"
|
||||
},
|
||||
"login": {
|
||||
"continueWith": "Oder weiter mit",
|
||||
"createAnAccount": "Ein Konto erstellen",
|
||||
"dontHaveAnAccount": "Du hast noch kein Konto?",
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"hints": {
|
||||
"invalidCredentials": "Die von dir eingegebenen Anmeldeinformationen sind ungültig. \nBitte versuche es erneut.",
|
||||
"linkSent": {
|
||||
"message": "Wir haben dir einen Link zum Fortfahren gesendet. \nBitte überprüfe deinen Posteingang.",
|
||||
"title": "Link gesendet"
|
||||
}
|
||||
},
|
||||
"loginWithPasskey": "Anmeldung mit Passkey",
|
||||
"modes": {
|
||||
"magicLink": "Magischer Link",
|
||||
"password": "Passwort"
|
||||
},
|
||||
"submit": "Anmelden",
|
||||
"subtitle": "Bitte gib deine Zugangsdaten ein, um dich anzumelden.",
|
||||
"title": "Willkommen zurück",
|
||||
"sendMagicLink": "Anmelde-Link senden"
|
||||
},
|
||||
"resetPassword": {
|
||||
"backToSignin": "Zurück zu Signin",
|
||||
"hints": {
|
||||
"error": "Es tut uns leid, aber wir konnten dein Passwort nicht zurücksetzen. Bitte versuche es erneut.",
|
||||
"success": "Dein Passwort wurde erfolgreich zurückgesetzt."
|
||||
},
|
||||
"message": "Bitte gib ein neues Passwort ein.",
|
||||
"newPassword": "Neues Passwort",
|
||||
"submit": "Passwort zurücksetzen",
|
||||
"title": "Dein Passwort zurücksetzen"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Du hast bereits ein Konto?",
|
||||
"email": "Email",
|
||||
"hints": {
|
||||
"signupFailed": "Es tut uns leid, aber wir konnten dein Konto nicht erstellen. \nBitte versuche es später noch einmal.",
|
||||
"verifyEmail": "Wir haben dir einen Link zur Bestätigung deiner E-Mail-Adresse gesendet. \nBitte überprüfe deinen Posteingang."
|
||||
},
|
||||
"message": "Wir freuen uns, dass du dich uns anschließen möchtest. \nBitte fülle das untenstehende Formular aus, um dein Konto zu erstellen.",
|
||||
"name": "Name",
|
||||
"password": "Passwort",
|
||||
"signIn": "Anmelden",
|
||||
"submit": "Benutzerkonto erstellen",
|
||||
"title": "Ein Konto erstellen"
|
||||
},
|
||||
"verify": {
|
||||
"backToSignin": "Zurück zur Anmeldung",
|
||||
"code": "Einmaliges Passwort",
|
||||
"message": "Gib das Einmalpasswort aus deiner Authentifizierungs-App ein, um fortzufahren.",
|
||||
"submit": "Verifizieren",
|
||||
"title": "Verifiziere dein Konto"
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"description": "In diesem Blog schreibe ich über alles mögliche.",
|
||||
"title": "Mein toller Blog",
|
||||
"back": "Zurück zum Blog"
|
||||
},
|
||||
"changelog": {
|
||||
"description": "Bleibe über die neuesten Änderungen in unserem Produkt auf dem Laufenden.",
|
||||
"title": "Changelog"
|
||||
},
|
||||
"common": {
|
||||
"confirmation": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen"
|
||||
},
|
||||
"menu": {
|
||||
"blog": "Blog",
|
||||
"changelog": "Changelog",
|
||||
"contact": "Kontakt",
|
||||
"dashboard": "Dashboard",
|
||||
"docs": "Dokumentation",
|
||||
"faq": "FAQ",
|
||||
"login": "Anmelden",
|
||||
"pricing": "Preise"
|
||||
},
|
||||
"tableOfContents": {
|
||||
"title": "Auf dieser Seite"
|
||||
},
|
||||
"actions": {
|
||||
"continue": "Weiter",
|
||||
"verify": "Überprüfen Sie"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"description": "Wir sind hier, um dir zu helfen. Bitte verwende das untenstehende Formular, um mit uns in Kontakt zu treten.",
|
||||
"form": {
|
||||
"email": "E-Mail",
|
||||
"message": "Nachricht",
|
||||
"name": "Name",
|
||||
"notifications": {
|
||||
"error": "Es tut uns leid, aber wir konnten deine Nachricht nicht senden. Bitte versuche es später noch einmal.",
|
||||
"success": "Deine Nachricht wurde erfolgreich abgeschickt. Wir werden uns so schnell wie möglich bei dir melden."
|
||||
},
|
||||
"submit": "Nachricht senden"
|
||||
},
|
||||
"title": "Kontakt"
|
||||
},
|
||||
"faq": {
|
||||
"description": "Hast du irgendwelche Fragen? \nWir sind für dich da.",
|
||||
"title": "Häufig gestellte Fragen"
|
||||
},
|
||||
"mail": {
|
||||
"common": {
|
||||
"openLinkInBrowser": "Wenn du den Link in einem anderen Browser als deinem Standardbrowser öffnen möchtest, kopiere diesen und füge ihn in die Adresszeile deines Browsers ein:",
|
||||
"otp": "Einmaliges Passwort",
|
||||
"useLink": "oder nutze den folgenden Link:"
|
||||
},
|
||||
"emailVerification": {
|
||||
"body": "Hallo!\nbitte klicke auf den unten stehenden Link, um diese neue E-Mail-Adresse zu bestätigen.",
|
||||
"confirmEmail": "E-Mail verifizieren",
|
||||
"subject": "E-Mail bestätigen"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"body": "Hallo,\n\ndu hast eine Passwortzurücksetzung beantragt.\n\n\nKlicke auf den unten stehenden Link, um dein Passwort zurückzusetzen.",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"subject": "Passwort zurücksetzen"
|
||||
},
|
||||
"magicLink": {
|
||||
"body": "Hey,\ndu hast eine Anmelde-E-Mail von supastarter angefordert.\n\nKlicke auf den unten stehenden Link, um dich anzumelden.",
|
||||
"login": "Login",
|
||||
"subject": "Login bei supastarter"
|
||||
},
|
||||
"newUser": {
|
||||
"body": "Hallo,\n\nVielen Dank, dass du dich bei Supastarter angemeldet hast.\n\n\nUm mit der Nutzung unserer App zu beginnen, bestätige bitte deine E-Mail-Adresse, indem du auf den untenstehenden Link klickst.",
|
||||
"confirmEmail": "E-Mail bestätigen",
|
||||
"subject": "E-Mail bestätigen"
|
||||
},
|
||||
"newsletterSignup": {
|
||||
"body": "Vielen Dank, dass du dich für den supastarter-Newsletter angemeldet hast. \nWir halten dich mit den neuesten Nachrichten und Updates auf dem Laufenden.",
|
||||
"subject": "Willkommen bei unserem Newsletter"
|
||||
},
|
||||
"organizationInvitation": {
|
||||
"body": "Du wurdest eingeladen, der Organisation {organizationName} beizutreten. Klicke auf den Button unten oder kopiere den Link und füge ihn in den Browser deiner Wahl ein, um die Einladung anzunehmen und der Organisation beizutreten.",
|
||||
"headline": "Einladung zur Organisation {organizationName}",
|
||||
"join": "Tritt der Organisation bei",
|
||||
"subject": "Du wurdest eingeladen, einer Organisation beizutreten"
|
||||
}
|
||||
},
|
||||
"newsletter": {
|
||||
"email": "Email",
|
||||
"hints": {
|
||||
"error": {
|
||||
"message": "Der Newsletter konnte nicht abonniert werden. Bitte versuche es später noch einmal."
|
||||
},
|
||||
"success": {
|
||||
"message": "Danke, dass du unseren Newsletter abonniert hast. \nWir werden dich auf dem Laufenden halten.",
|
||||
"title": "Erfolgreich angemeldet!"
|
||||
}
|
||||
},
|
||||
"submit": "Abonnieren",
|
||||
"subtitle": "Gehöre zu den Ersten, die Zugriff auf supastarter.nextjs erhalten.",
|
||||
"title": "Erhalte frühen Zugang"
|
||||
},
|
||||
"onboarding": {
|
||||
"account": {
|
||||
"avatar": "Avatar",
|
||||
"avatarDescription": "Klicke auf den Kreis oder ziehe ein Bild hinein, um deinen Avatar hochzuladen.",
|
||||
"name": "Name"
|
||||
},
|
||||
"continue": "Weiter",
|
||||
"message": "Nur ein paar schnelle Schritte, um dir den Einstieg zu erleichtern.",
|
||||
"notifications": {
|
||||
"accountSetupFailed": "Es tut uns leid, aber wir konnten dein Konto nicht einrichten. \nBitte versuche es später noch einmal."
|
||||
},
|
||||
"step": "Schritt {step} / {total}",
|
||||
"title": "Richte dein Konto ein"
|
||||
},
|
||||
"organizations": {
|
||||
"createForm": {
|
||||
"name": "Name der Organisation",
|
||||
"notifications": {
|
||||
"error": "Es tut uns leid, aber wir konnten deine Organisation nicht erstellen. Bitte versuche es später noch einmal.",
|
||||
"success": "Deine Organisation wurde erstellt. Du kannst nun Mitglieder einladen."
|
||||
},
|
||||
"submit": "Organisation erstellen",
|
||||
"subtitle": "Gib einen Namen für deine Organisation ein, um loszulegen. Du kannst den Namen später in den Organisationseinstellungen ändern.",
|
||||
"title": "Erstelle eine Organisation"
|
||||
},
|
||||
"invitationAlert": {
|
||||
"description": "Du bist eingeladen worden, einer Organisation beizutreten. Wenn du dich anmeldest oder registrierst, akzeptierst du automatisch die Einladung und trittst der Organisation bei.",
|
||||
"title": "Einladung der Organisation annehmen"
|
||||
},
|
||||
"invitationModal": {
|
||||
"accept": "Annehmen",
|
||||
"decline": "Ablehnen",
|
||||
"description": "Du wurdest eingeladen, der Organisation {organizationName} beizutreten. Möchtest du die Einladung annehmen und der Organisation beitreten?",
|
||||
"title": "Organisation beitreten"
|
||||
},
|
||||
"organizationSelect": {
|
||||
"createNewOrganization": "Neue Organisation erstellen",
|
||||
"organizations": "Organisationen",
|
||||
"personalAccount": "Persönliches Konto"
|
||||
},
|
||||
"organizationsGrid": {
|
||||
"createNewOrganization": "Neue Organisation erstellen",
|
||||
"title": "Deine Organisationen"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administration",
|
||||
"member": "Mitglied",
|
||||
"owner": "Eigentümer"
|
||||
},
|
||||
"settings": {
|
||||
"changeName": {
|
||||
"title": "Name der Organisation"
|
||||
},
|
||||
"deleteOrganization": {
|
||||
"confirmation": "Bist du sicher, dass du deine Organisation löschen möchtest?",
|
||||
"description": "Lösche deine Organisation dauerhaft. Sobald du deine Organisation gelöscht hast, gibt es kein Zurück mehr. Zur Bestätigung gib bitte unten dein Passwort ein:",
|
||||
"submit": "Organisation löschen",
|
||||
"title": "Organisation löschen"
|
||||
},
|
||||
"logo": {
|
||||
"description": "Lade ein Logo für deine Organisation hoch.",
|
||||
"title": "Logo der Organisation"
|
||||
},
|
||||
"members": {
|
||||
"activeMembers": "Aktive Mitglieder",
|
||||
"description": "Sieh dir alle aktiven Mitglieder und die ausstehenden Einladungen deiner Organisation an.",
|
||||
"invitations": {
|
||||
"empty": "Es wurden noch keine Mitglieder eingeladen.",
|
||||
"expiresAt": "Läuft am {date} ab",
|
||||
"invitationStatus": {
|
||||
"accepted": "Angenommen",
|
||||
"canceled": "Abgebrochen",
|
||||
"pending": "Ausstehend",
|
||||
"rejected": "Abgelehnt"
|
||||
},
|
||||
"revoke": "Einladung widerrufen",
|
||||
"resend": "Einladung erneut senden"
|
||||
},
|
||||
"inviteMember": {
|
||||
"description": "Um ein neues Mitglied einzuladen, sende ihm eine Einladung.",
|
||||
"email": "E-Mail",
|
||||
"notifications": {
|
||||
"error": {
|
||||
"description": "Wir konnten das Mitglied nicht einladen. Bitte versuche es später noch einmal.",
|
||||
"title": "Mitglied konnte nicht eingeladen werden"
|
||||
},
|
||||
"success": {
|
||||
"description": "Das Mitglied ist eingeladen worden.",
|
||||
"title": "Eingeladenes Mitglied"
|
||||
}
|
||||
},
|
||||
"role": "Rolle",
|
||||
"submit": "Einladen",
|
||||
"title": "Mitglied einladen"
|
||||
},
|
||||
"leaveOrganization": "Organisation verlassen",
|
||||
"notifications": {
|
||||
"removeMember": {
|
||||
"error": {
|
||||
"description": "Das Mitglied konnte nicht aus deiner Organisation entfernt werden. Bitte versuche es erneut."
|
||||
},
|
||||
"loading": {
|
||||
"description": "Mitglied aus der Organisation entfernen..."
|
||||
},
|
||||
"success": {
|
||||
"description": "Das Mitglied wurde erfolgreich aus deiner Organisation entfernt."
|
||||
}
|
||||
},
|
||||
"revokeInvitation": {
|
||||
"error": {
|
||||
"description": "Die Einladung konnte nicht widerrufen werden. Bitte versuche es später noch einmal."
|
||||
},
|
||||
"loading": {
|
||||
"description": "Widerruf der Einladung..."
|
||||
},
|
||||
"success": {
|
||||
"description": "Die Einladung ist widerrufen worden."
|
||||
}
|
||||
},
|
||||
"updateMembership": {
|
||||
"error": {
|
||||
"description": "Die Mitgliedschaft in der Organisation konnte nicht aktualisiert werden. Bitte versuche es erneut."
|
||||
},
|
||||
"loading": {
|
||||
"description": "Aktualisierung der Mitgliedschaft..."
|
||||
},
|
||||
"success": {
|
||||
"description": "Die Mitgliedschaft wurde erfolgreich aktualisiert"
|
||||
}
|
||||
},
|
||||
"resendInvitation": {
|
||||
"error": {
|
||||
"description": "Die Einladung konnte nicht gesendet werden. Bitte versuche es erneut."
|
||||
},
|
||||
"loading": {
|
||||
"description": "Einladung erneut senden..."
|
||||
},
|
||||
"success": {
|
||||
"description": "Einladung verschickt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pendingInvitations": "Ausstehende Einladungen",
|
||||
"removeMember": "Mitglied entfernen",
|
||||
"title": "Mitglieder"
|
||||
},
|
||||
"notifications": {
|
||||
"organizationDeleted": "Deine Organisation wurde gelöscht.",
|
||||
"organizationNameNotUpdated": "Wir konnten den Namen deiner Organisation nicht aktualisieren. Bitte versuche es später noch einmal.",
|
||||
"organizationNameUpdated": "Der Name deiner Organisation wurde aktualisiert.",
|
||||
"organizationNotDeleted": "Wir konnten deine Organisation nicht löschen. Bitte versuche es später noch einmal."
|
||||
},
|
||||
"subtitle": "Verwalte die Einstellungen der Organisation.",
|
||||
"title": "Organisation",
|
||||
"dangerZone": {
|
||||
"title": "Gefahrenzone"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"subtitle": "Willkommen auf der Startseite dieser Organisation!"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"choosePlan": "Plan auswählen",
|
||||
"contactSales": "Vertrieb kontaktieren",
|
||||
"description": "Wähle das passende Paket für dich aus.",
|
||||
"getStarted": "Jetzt loslegen",
|
||||
"month": "Monat",
|
||||
"monthly": "Monatlich",
|
||||
"products": {
|
||||
"basic": {
|
||||
"description": "Perfekt für den Einstieg",
|
||||
"features": {
|
||||
"anotherFeature": "Ein weiteres erstaunliches Merkmal",
|
||||
"limitedSupport": "Begrenzter Support"
|
||||
},
|
||||
"title": "Basic"
|
||||
},
|
||||
"enterprise": {
|
||||
"description": "Maßgeschneiderter Plan für deine Anforderungen",
|
||||
"features": {
|
||||
"enterpriseSupport": "Support für Unternehmen",
|
||||
"unlimitedProjects": "Unbegrenzte Projekte"
|
||||
},
|
||||
"title": "Enterprise"
|
||||
},
|
||||
"free": {
|
||||
"description": "Kostenloser Start",
|
||||
"features": {
|
||||
"anotherFeature": "Ein weiteres erstaunliches Merkmal",
|
||||
"limitedSupport": "Begrenzter Support"
|
||||
},
|
||||
"title": "Free"
|
||||
},
|
||||
"lifetime": {
|
||||
"description": "Einmal kaufen. Für immer verwenden.",
|
||||
"features": {
|
||||
"extendSupport": "Erweiterter Support",
|
||||
"noRecurringCosts": "Keine wiederkehrenden Kosten"
|
||||
},
|
||||
"title": "Lifetime"
|
||||
},
|
||||
"pro": {
|
||||
"description": "Am besten für Teams",
|
||||
"features": {
|
||||
"anotherFeature": "Ein weiteres erstaunliches Merkmal",
|
||||
"fiveMembers": "Bis zu 5 Mitglieder",
|
||||
"fullSupport": "Voller Support"
|
||||
},
|
||||
"title": "Pro"
|
||||
}
|
||||
},
|
||||
"purchase": "Kaufen",
|
||||
"recommended": "Empfohlen",
|
||||
"subscribe": "Abonnieren",
|
||||
"title": "Preise",
|
||||
"trialPeriod": "{days} {days, plural, one {Tag} other {Tage}} kostenlose Testphase",
|
||||
"year": "Jahr",
|
||||
"yearly": "Jährlich",
|
||||
"perSeat": "Sitz"
|
||||
},
|
||||
"settings": {
|
||||
"account": {
|
||||
"avatar": {
|
||||
"description": "Um deinen Avatar zu ändern, klicke auf das Bild in diesem Block und wähle eine Datei von deinem Computer zum Hochladen aus.",
|
||||
"notifications": {
|
||||
"error": "Avatar konnte nicht aktualisiert werden",
|
||||
"success": "Avatar wurde erfolgreich aktualisiert"
|
||||
},
|
||||
"title": "Dein Avatar"
|
||||
},
|
||||
"changeEmail": {
|
||||
"description": "Um deine E-Mail zu ändern, ändere sie im Eingabefeld und drücke auf Speichern. Du musst die neue E-Mail bestätigen, bevor sie aktiv wird.",
|
||||
"notifications": {
|
||||
"error": "E-Mail konnte nicht aktualisiert werden",
|
||||
"success": "E-Mail wurde erfolgreich aktualisiert"
|
||||
},
|
||||
"title": "Deine E-Mail"
|
||||
},
|
||||
"changeName": {
|
||||
"notifications": {
|
||||
"error": "Name konnte nicht aktualisiert werden",
|
||||
"success": "Name wurde erfolgreich aktualisiert"
|
||||
},
|
||||
"title": "Dein Name"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"confirmation": "Bist du sicher, dass du dein Konto löschen möchtest?",
|
||||
"description": "Lösche dein Konto dauerhaft. \nSobald Du dein Konto gelöscht hast, gibt es kein Zurück mehr. \nBitte sei dir sicher, bevor du hier weitermachst.",
|
||||
"notifications": {
|
||||
"error": "Konto konnte nicht gelöscht werden",
|
||||
"success": "Konto wurde erfolgreich gelöscht"
|
||||
},
|
||||
"submit": "Konto löschen",
|
||||
"title": "Konto löschen"
|
||||
},
|
||||
"language": {
|
||||
"description": "Um die Sprache der App für dein Konto zu ändern, wähle eine Sprache aus der Liste aus und klicke auf Speichern.",
|
||||
"notifications": {
|
||||
"error": "Sprache konnte nicht aktualisiert werden",
|
||||
"success": "Sprache wurde erfolgreich aktualisiert"
|
||||
},
|
||||
"title": "Deine Sprache"
|
||||
},
|
||||
"security": {
|
||||
"activeSessions": {
|
||||
"description": "Dies sind alle aktiven Sitzungen deines Kontos. Klicke auf das X, um eine bestimmte Sitzung zu beenden.",
|
||||
"title": "Aktive Sitzungen",
|
||||
"notifications": {
|
||||
"revokeSession": {
|
||||
"success": "Session beendet"
|
||||
}
|
||||
},
|
||||
"currentSession": "Aktuelle Sitzung"
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"notifications": {
|
||||
"error": "Passwort kann nicht aktualisiert werden",
|
||||
"success": "Passwort wurde erfolgreich aktualisiert"
|
||||
},
|
||||
"title": "Dein Passwort"
|
||||
},
|
||||
"connectedAccounts": {
|
||||
"connect": "Verbinden",
|
||||
"disconnect": "Trennen",
|
||||
"title": "Verbundene Konten"
|
||||
},
|
||||
"passkeys": {
|
||||
"description": "Verwende Passkeys als sichere alternative zu Passwörtern.",
|
||||
"notifications": {
|
||||
"addPasskey": {
|
||||
"error": {
|
||||
"title": "Kann keinen Hauptschlüssel hinzufügen"
|
||||
},
|
||||
"success": {
|
||||
"title": "Passkey hinzugefügt"
|
||||
}
|
||||
},
|
||||
"deletePasskey": {
|
||||
"error": {
|
||||
"title": "Der Hauptschlüssel konnte nicht gelöscht werden"
|
||||
},
|
||||
"loading": {
|
||||
"title": "Hauptschlüssel löschen..."
|
||||
},
|
||||
"success": {
|
||||
"title": "Passkey gelöscht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Passwörter"
|
||||
},
|
||||
"setPassword": {
|
||||
"description": "Du hast noch kein Passwort festgelegt. Um ein solches festzulegen, musst du den Ablauf der Passwortrücksetzung durchlaufen. Klicke auf die Schaltfläche unten, um eine E-Mail zum Zurücksetzen deines Passworts zu senden, und folge den Anweisungen in der E-Mail.",
|
||||
"notifications": {
|
||||
"error": "Der Link zum Festlegen des Passworts konnte nicht gesendet werden. Bitte versuche es erneut.",
|
||||
"success": "Prüfen deinen Posteingang auf den Link, um Ihr Passwort festzulegen."
|
||||
},
|
||||
"submit": "Passwort festlegen",
|
||||
"title": "Dein Passwort"
|
||||
},
|
||||
"title": "Sicherheit",
|
||||
"twoFactor": {
|
||||
"description": "Füge deinem Konto eine zusätzliche Sicherheitsebene hinzu.",
|
||||
"dialog": {
|
||||
"password": {
|
||||
"description": "Bitte verifiziere dein Konto, indem du dein Passwort eingibst:",
|
||||
"label": "Dein Passwort:",
|
||||
"title": "Mit Passwort verifizieren"
|
||||
},
|
||||
"totpUrl": {
|
||||
"code": "Geben Sie den 6-stelligen Code ein, um die Einrichtung zu bestätigen:",
|
||||
"description": "Verwende deine bevorzugte Authentifizierungs-App und scanne den QR-Code damit oder gib das unten stehende Geheimnis manuell ein, um die Zwei-Faktor-Authentifizierung einzurichten.",
|
||||
"title": "Zwei-Faktor-Authentifizierung aktivieren"
|
||||
}
|
||||
},
|
||||
"disable": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"enable": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enabled": "Du hast die Zwei-Faktor-Authentifizierung für dein Konto aktiviert.",
|
||||
"notifications": {
|
||||
"disable": {
|
||||
"success": {
|
||||
"title": "Die Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert."
|
||||
}
|
||||
},
|
||||
"enable": {
|
||||
"error": {
|
||||
"title": "Dein Konto konnte mit dem angegebenen Passwort nicht verifiziert werden. Bitte versuche es erneut."
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"success": {
|
||||
"title": "Die Zwei-Faktor-Authentifizierung wurde erfolgreich aktiviert."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Zwei-Faktor-Authentifizierung"
|
||||
}
|
||||
},
|
||||
"subtitle": "Verwalte die Einstellungen deines persönlichen Kontos.",
|
||||
"title": "Kontoeinstellungen"
|
||||
},
|
||||
"billing": {
|
||||
"createCustomerPortal": {
|
||||
"label": "Abrechung verwalten",
|
||||
"notifications": {
|
||||
"error": {
|
||||
"title": "Es konnte keine Kundenportalsitzung erstellt werden. \nBitte versuche es erneut."
|
||||
}
|
||||
}
|
||||
},
|
||||
"activePlan": {
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"canceled": "Gekündigt",
|
||||
"expired": "Ausgelaufen",
|
||||
"incomplete": "Unvollständig",
|
||||
"past_due": "Überfällig",
|
||||
"paused": "Pausiert",
|
||||
"trialing": "Testversion",
|
||||
"unpaid": "Unbezahlt"
|
||||
},
|
||||
"title": "Dein Plan"
|
||||
},
|
||||
"changePlan": {
|
||||
"description": "Wähle einen Plan aus, den du abonnieren möchtest.",
|
||||
"title": "Ändere deinen Plan"
|
||||
},
|
||||
"title": "Zahlung"
|
||||
},
|
||||
"menu": {
|
||||
"account": {
|
||||
"billing": "Abrechnung",
|
||||
"dangerZone": "Danger-Zone",
|
||||
"general": "Allgemein",
|
||||
"security": "Sicherheit",
|
||||
"title": "Konto"
|
||||
},
|
||||
"organization": {
|
||||
"billing": "Abrechnung",
|
||||
"general": "Allgemein",
|
||||
"members": "Mitglieder",
|
||||
"title": "Organisation",
|
||||
"dangerZone": "Gefahrenzone"
|
||||
}
|
||||
},
|
||||
"save": "Speichern"
|
||||
},
|
||||
"start": {
|
||||
"subtitle": "Sieh dir die neuesten Statistiken über dein großartiges Unternehmen an.",
|
||||
"welcome": "Willkommen {name}!"
|
||||
},
|
||||
"zod": {
|
||||
"errors": {
|
||||
"invalid_arguments": "Ungültige Funktionsargumente",
|
||||
"invalid_date": "Ungültiges Datum",
|
||||
"invalid_enum_value": "Ungültiger Enum-Wert. {options} erwartet, {received} erhalten",
|
||||
"invalid_intersection_types": "Schnittmengenergebnisse konnten nicht zusammengeführt werden",
|
||||
"invalid_literal": "Ungültiger Literalwert, {expected} erwartet",
|
||||
"invalid_return_type": "Ungültiger Funktionsrückgabewert",
|
||||
"invalid_string": {
|
||||
"cuid": "Ungültige {validation}",
|
||||
"datetime": "Ungültige {validation}",
|
||||
"email": "Ungültige {validation}",
|
||||
"endsWith": "Ungültige Eingabe: muss mit \"{endsWith}\" enden",
|
||||
"regex": "Ungültig",
|
||||
"startsWith": "Ungültige Eingabe: muss mit \"{startsWith}\" beginnen",
|
||||
"url": "Ungültige {validation}",
|
||||
"uuid": "Ungültige {validation}"
|
||||
},
|
||||
"invalid_type": "{expected} erwartet, {received} erhalten",
|
||||
"invalid_type_received_undefined": "Darf nicht leer sein",
|
||||
"invalid_union": "Ungültige Eingabe",
|
||||
"invalid_union_discriminator": "Ungültiger Diskriminatorwert, {options} erwartet",
|
||||
"not_finite": "Zahl muss endlich sein",
|
||||
"not_multiple_of": "Zahl muss ein Vielfaches von {multipleOf} sein",
|
||||
"too_big": {
|
||||
"array": {
|
||||
"exact": "Array muss genau {maximum} Element(e) enthalten",
|
||||
"inclusive": "Array muss höchstens {maximum} Element(e) enthalten",
|
||||
"not_inclusive": "Array muss weniger als {maximum} Element(e) enthalten"
|
||||
},
|
||||
"date": {
|
||||
"exact": "Datum muss genau {- maximum, datetime} sein",
|
||||
"inclusive": "Datum muss kleiner oder gleich {- maximum, datetime} sein",
|
||||
"not_inclusive": "Datum muss kleiner sein als {- maximum, datetime}"
|
||||
},
|
||||
"number": {
|
||||
"exact": "Zahl muss genau {maximum} sein",
|
||||
"inclusive": "Zahl muss kleiner oder gleich {maximum} sein",
|
||||
"not_inclusive": "Zahl muss kleiner sein als {maximum}"
|
||||
},
|
||||
"set": {
|
||||
"exact": "Ungültige Eingabe",
|
||||
"inclusive": "Ungültige Eingabe",
|
||||
"not_inclusive": "Ungültige Eingabe"
|
||||
},
|
||||
"string": {
|
||||
"exact": "String muss genau {maximum} Zeichen enthalten",
|
||||
"inclusive": "Die Zeichenfolge darf höchstens {maximum} Zeichen enthalten",
|
||||
"not_inclusive": "String muss unter {maximum} Zeichen enthalten"
|
||||
}
|
||||
},
|
||||
"too_small": {
|
||||
"array": {
|
||||
"exact": "Array muss genau {minimum} Element(e) enthalten",
|
||||
"inclusive": "Array muss mindestens {minimum} Element(e) enthalten",
|
||||
"not_inclusive": "Array muss mehr als {minimum} Element(e) enthalten"
|
||||
},
|
||||
"date": {
|
||||
"exact": "Datum muss genau {- minimum, datetime} sein",
|
||||
"inclusive": "Datum muss größer oder gleich {- minimum, datetime} sein",
|
||||
"not_inclusive": "Datum muss größer sein als {- minimum, datetime}"
|
||||
},
|
||||
"number": {
|
||||
"exact": "Zahl muss genau {minimum} sein",
|
||||
"inclusive": "Zahl muss größer oder gleich {minimum} sein",
|
||||
"not_inclusive": "Zahl muss größer sein als {minimum}"
|
||||
},
|
||||
"set": {
|
||||
"exact": "Ungültige Eingabe",
|
||||
"inclusive": "Ungültige Eingabe",
|
||||
"not_inclusive": "Ungültige Eingabe"
|
||||
},
|
||||
"string": {
|
||||
"exact": "Zeichenfolge muss genau {minimum} Zeichen enthalten",
|
||||
"inclusive": "Zeichenkette muss mindestens {minimum} Zeichen enthalten",
|
||||
"not_inclusive": "Zeichenfolge muss mehr als {minimum} Zeichen enthalten"
|
||||
}
|
||||
},
|
||||
"unrecognized_keys": "Unbekannte Schlüssel im Objekt: {keys}"
|
||||
},
|
||||
"types": {
|
||||
"array": "Array",
|
||||
"bigint": "BigInt",
|
||||
"boolean": "Boolean",
|
||||
"date": "Datum",
|
||||
"float": "Float",
|
||||
"function": "Funktion",
|
||||
"integer": "Integer",
|
||||
"map": "Map",
|
||||
"nan": "NaN",
|
||||
"never": "Nicht definiert",
|
||||
"null": "Null",
|
||||
"number": "Zahl",
|
||||
"object": "Objekt",
|
||||
"promise": "Promise",
|
||||
"set": "Set",
|
||||
"string": "String",
|
||||
"symbol": "Symbol",
|
||||
"undefined": "Undefined",
|
||||
"unknown": "Unbekannt",
|
||||
"void": "Void"
|
||||
},
|
||||
"validations": {
|
||||
"cuid": "Cuid",
|
||||
"cuid2": "Cuid2",
|
||||
"datetime": "Datum",
|
||||
"email": "E-Mail",
|
||||
"emoji": "Emoji",
|
||||
"ip": "IP",
|
||||
"regex": "Regex",
|
||||
"ulid": "Ulid",
|
||||
"url": "URL",
|
||||
"uuid": "Uuid"
|
||||
}
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Dokumentation"
|
||||
},
|
||||
"choosePlan": {
|
||||
"title": "Wähle einen Plan",
|
||||
"description": "Um weiterzumachen, wähle bitte einen Plan aus."
|
||||
}
|
||||
}
|
||||
826
packages/i18n/translations/en.json
Normal file
826
packages/i18n/translations/en.json
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
{
|
||||
"admin": {
|
||||
"menu": {
|
||||
"organizations": "Organizations",
|
||||
"users": "Users"
|
||||
},
|
||||
"organizations": {
|
||||
"backToList": "Back to organizations",
|
||||
"confirmDelete": {
|
||||
"confirm": "Delete",
|
||||
"message": "Are you sure you want to delete this organization? This action cannot be undone.",
|
||||
"title": "Delete organization"
|
||||
},
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"deleteOrganization": {
|
||||
"deleted": "Organization has been deleted successfully!",
|
||||
"deleting": "Deleting organization...",
|
||||
"notDeleted": "Organization could not be deleted. Please try again."
|
||||
},
|
||||
"edit": "Edit",
|
||||
"form": {
|
||||
"createTitle": "Create an organization",
|
||||
"name": "Organization name",
|
||||
"notifications": {
|
||||
"error": "Could not save the organization. Please try again later.",
|
||||
"success": "Organization has been saved."
|
||||
},
|
||||
"save": "Save",
|
||||
"updateTitle": "Edit organization"
|
||||
},
|
||||
"loading": "Loading organizations...",
|
||||
"membersCount": "{count} {count, plural, one {member} other {members}}",
|
||||
"search": "Search for an organization...",
|
||||
"title": "Manage organizations"
|
||||
},
|
||||
"title": "Administration",
|
||||
"users": {
|
||||
"confirmDelete": {
|
||||
"confirm": "Delete",
|
||||
"message": "Are you sure you want to delete this user? This action cannot be undone.",
|
||||
"title": "Delete user"
|
||||
},
|
||||
"delete": "Delete",
|
||||
"deleteUser": {
|
||||
"deleted": "User has been deleted successfully!",
|
||||
"deleting": "Deleting user...",
|
||||
"notDeleted": "User could not be deleted. Please try again."
|
||||
},
|
||||
"emailVerified": {
|
||||
"verified": "Email verified",
|
||||
"waiting": "Email waiting for verification"
|
||||
},
|
||||
"impersonate": "Impersonate",
|
||||
"impersonation": {
|
||||
"impersonating": "Impersonating as {name}..."
|
||||
},
|
||||
"loading": "Loading users...",
|
||||
"resendVerificationMail": {
|
||||
"error": "Could not resend verification mail. Please try again.",
|
||||
"submitting": "Resending verification mail...",
|
||||
"success": "Verification mail has been sent.",
|
||||
"title": "Resend verification mail"
|
||||
},
|
||||
"search": "Search for name or email...",
|
||||
"title": "Manage users",
|
||||
"assignAdminRole": "Assign admin role",
|
||||
"removeAdminRole": "Remove admin role"
|
||||
},
|
||||
"description": "Manage your application."
|
||||
},
|
||||
"app": {
|
||||
"menu": {
|
||||
"accountSettings": "Account settings",
|
||||
"admin": "Admin",
|
||||
"aiChatbot": "AI Chatbot",
|
||||
"organizationSettings": "Organization settings",
|
||||
"start": "Start"
|
||||
},
|
||||
"userMenu": {
|
||||
"accountSettings": "Account settings",
|
||||
"colorMode": "Color mode",
|
||||
"documentation": "Documentation",
|
||||
"home": "Home",
|
||||
"logout": "Logout"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"invalidEmailOrPassword": "The credentials you entered are invalid. Please check them and try again.",
|
||||
"unknown": "Something went wrong. Please try again.",
|
||||
"userNotFound": "This user does not exists",
|
||||
"failedToCreateUser": "Could not create user. Please try again.",
|
||||
"failedToCreateSession": "Could not create a session. Please try again.",
|
||||
"failedToUpdateUser": "Could not update user. Please try again.",
|
||||
"failedToGetSession": "Could not get the session.",
|
||||
"invalidPassword": "The entered password is incorrect.",
|
||||
"invalidEmail": "The entered email is invalid.",
|
||||
"invalidToken": "The token you entered is invalid or has expired.",
|
||||
"credentialAccountNotFound": "Account not found.",
|
||||
"emailCanNotBeUpdated": "Email could not be updated. Please try again.",
|
||||
"emailNotVerified": "Please verify your email first before logging in.",
|
||||
"failedToGetUserInfo": "Could not load user information.",
|
||||
"idTokenNotSupported": "ID token is not supported.",
|
||||
"passwordTooLong": "Password is too long.",
|
||||
"passwordTooShort": "Password is too short.",
|
||||
"providerNotFound": "This provider is not suppported.",
|
||||
"socialAccountAlreadyLinked": "This account is already linked to a user.",
|
||||
"userEmailNotFound": "Email not found.",
|
||||
"userAlreadyExists": "This user already exists.",
|
||||
"invalidInvitation": "The invitation is invalid or expired.",
|
||||
"sessionExpired": "The session has expired.",
|
||||
"failedToUnlinkLastAccount": "Failed to unlink account",
|
||||
"accountNotFound": "Account not found"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"backToSignin": "Back to signin",
|
||||
"email": "Email",
|
||||
"hints": {
|
||||
"linkNotSent": {
|
||||
"message": "We are sorry, but we were unable to send you a link to reset your password. Please try again later.",
|
||||
"title": "Link not sent"
|
||||
},
|
||||
"linkSent": {
|
||||
"message": "We have sent you a link to continue. Please check your inbox.",
|
||||
"title": "Link sent"
|
||||
}
|
||||
},
|
||||
"message": "Please enter your email address and we will send you a link to reset your password.",
|
||||
"submit": "Send link",
|
||||
"title": "Forgot your password?"
|
||||
},
|
||||
"login": {
|
||||
"continueWith": "Or continue with",
|
||||
"createAnAccount": "Create an account",
|
||||
"dontHaveAnAccount": "Don't have an account yet?",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"hints": {
|
||||
"invalidCredentials": "The email or password you entered are invalid. Please try again.",
|
||||
"linkSent": {
|
||||
"message": "We have sent you a link to continue. Please check your inbox.",
|
||||
"title": "Link sent"
|
||||
}
|
||||
},
|
||||
"loginWithPasskey": "Login with passkey",
|
||||
"modes": {
|
||||
"magicLink": "Magic link",
|
||||
"password": "Password"
|
||||
},
|
||||
"submit": "Sign in",
|
||||
"subtitle": "Please enter your credentials to sign in.",
|
||||
"title": "Welcome back",
|
||||
"sendMagicLink": "Send magic link"
|
||||
},
|
||||
"resetPassword": {
|
||||
"backToSignin": "Back to signin",
|
||||
"hints": {
|
||||
"error": "We are sorry, but we were unable to reset your password. Please try again.",
|
||||
"success": "Your password has been reset successfully."
|
||||
},
|
||||
"message": "Please enter a new password.",
|
||||
"newPassword": "New password",
|
||||
"submit": "Reset password",
|
||||
"title": "Reset your password"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"email": "Email",
|
||||
"hints": {
|
||||
"signupFailed": "We are sorry, but we were unable to create your account. Please try again later.",
|
||||
"verifyEmail": "We have sent you a link to verify your email. Please check your inbox."
|
||||
},
|
||||
"message": "We are happy that you want to join us. Please fill in the form below to create your account.",
|
||||
"name": "Name",
|
||||
"password": "Password",
|
||||
"signIn": "Sign in",
|
||||
"submit": "Create account",
|
||||
"title": "Create an account"
|
||||
},
|
||||
"verify": {
|
||||
"title": "Verify your account",
|
||||
"code": "One-time password",
|
||||
"submit": "Verify",
|
||||
"backToSignin": "Back to login",
|
||||
"message": "Enter the one-time password from your authenticator app to continue."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"description": "Read the latest news from our company",
|
||||
"title": "My awesome blog",
|
||||
"back": "Back to blog"
|
||||
},
|
||||
"changelog": {
|
||||
"description": "Stay up to date with the latest changes in our product.",
|
||||
"title": "Changelog"
|
||||
},
|
||||
"common": {
|
||||
"confirmation": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"menu": {
|
||||
"blog": "Blog",
|
||||
"changelog": "Changelog",
|
||||
"contact": "Contact",
|
||||
"dashboard": "Dashboard",
|
||||
"docs": "Docs",
|
||||
"faq": "FAQ",
|
||||
"login": "Login",
|
||||
"pricing": "Pricing"
|
||||
},
|
||||
"tableOfContents": {
|
||||
"title": "On this page"
|
||||
},
|
||||
"actions": {
|
||||
"continue": "Continue",
|
||||
"verify": "Verify"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"description": "We are here to help you. Please use the form below to get in touch with us.",
|
||||
"form": {
|
||||
"email": "Email",
|
||||
"message": "Message",
|
||||
"name": "Name",
|
||||
"notifications": {
|
||||
"error": "We are sorry, but we were unable to send your message. Please try again later.",
|
||||
"success": "Your message has been sent successfully. We will get back to you as soon as possible."
|
||||
},
|
||||
"submit": "Send message"
|
||||
},
|
||||
"title": "Contact us"
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Documentation"
|
||||
},
|
||||
"faq": {
|
||||
"description": "Do you have any questions? We have got you covered.",
|
||||
"title": "Frequently asked questions"
|
||||
},
|
||||
"mail": {
|
||||
"common": {
|
||||
"openLinkInBrowser": "If you want to open the link in a different browser than your default one, copy and paste this link:",
|
||||
"otp": "One-time password",
|
||||
"useLink": "or use the following link:"
|
||||
},
|
||||
"emailVerification": {
|
||||
"body": "Hey,\nplease click the link below to verify this new email address.",
|
||||
"confirmEmail": "Verify email",
|
||||
"subject": "Verify your email"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"body": "Hey,\nyou requested a password reset.\n\nClick the button below to reset your password.",
|
||||
"resetPassword": "Reset password",
|
||||
"subject": "Reset your password"
|
||||
},
|
||||
"magicLink": {
|
||||
"body": "Hey,\nyou requested a login email from supastarter.\n\nClick the link below to login.",
|
||||
"login": "Login",
|
||||
"subject": "Login to supastarter"
|
||||
},
|
||||
"newUser": {
|
||||
"body": "Hey,\nthanks for signing up for supastarter.\n\nTo start using our app, please confirm your email address by clicking the link below.",
|
||||
"confirmEmail": "Confirm email",
|
||||
"subject": "Confirm your email"
|
||||
},
|
||||
"newsletterSignup": {
|
||||
"body": "Thank you for signing up for the supastarter newsletter. We will keep you updated with the latest news and updates.",
|
||||
"subject": "Welcome to our newsletter"
|
||||
},
|
||||
"organizationInvitation": {
|
||||
"body": "You have been invited to join the organization {organizationName}. Click the button below or copy and paste the link into your browser of choice to accept the invitation and join the organization.",
|
||||
"headline": "Join the organization {organizationName}",
|
||||
"join": "Join the organization",
|
||||
"subject": "You have been invited to join an organization"
|
||||
}
|
||||
},
|
||||
"newsletter": {
|
||||
"email": "Email",
|
||||
"hints": {
|
||||
"error": {
|
||||
"message": "Could not subscribe to newsletter. Please try again later."
|
||||
},
|
||||
"success": {
|
||||
"message": "Thank you for subscribing to our newsletter. We will keep you posted.",
|
||||
"title": "Subscribed"
|
||||
}
|
||||
},
|
||||
"submit": "Subscribe",
|
||||
"subtitle": "Be among the first to get access to supastarter.nextjs.",
|
||||
"title": "Get early access"
|
||||
},
|
||||
"onboarding": {
|
||||
"account": {
|
||||
"avatar": "Avatar",
|
||||
"avatarDescription": "Click the circle or drop an image to it to upload your avatar.",
|
||||
"name": "Name"
|
||||
},
|
||||
"continue": "Continue",
|
||||
"message": "Just a few quick steps to get you started.",
|
||||
"notifications": {
|
||||
"accountSetupFailed": "We are sorry, but we were unable to set up your account. Please try again later."
|
||||
},
|
||||
"step": "Step {step} / {total}",
|
||||
"title": "Set up your account"
|
||||
},
|
||||
"organizations": {
|
||||
"createForm": {
|
||||
"name": "Organization name",
|
||||
"notifications": {
|
||||
"error": "We are sorry, but we were unable to create your organization. Please try again later.",
|
||||
"success": "Your organization has been created. You can now invite members."
|
||||
},
|
||||
"submit": "Create organization",
|
||||
"subtitle": "Enter a name for your organization to get started. You can change the name later in the organization settings.",
|
||||
"title": "Create an organization"
|
||||
},
|
||||
"invitationAlert": {
|
||||
"description": "You need to sign in or create an account to join the organization.",
|
||||
"title": "You have been invited to join an organization."
|
||||
},
|
||||
"invitationModal": {
|
||||
"accept": "Accept",
|
||||
"decline": "Decline",
|
||||
"description": "You have been invited to join the organization {organizationName}. Do you want to accept the invitation and join the organization?",
|
||||
"title": "Join the organization"
|
||||
},
|
||||
"organizationSelect": {
|
||||
"createNewOrganization": "Create new organization",
|
||||
"organizations": "Organizations",
|
||||
"personalAccount": "Personal account"
|
||||
},
|
||||
"organizationsGrid": {
|
||||
"createNewOrganization": "Create new organization",
|
||||
"title": "Your organizations"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"member": "Member",
|
||||
"owner": "Owner"
|
||||
},
|
||||
"settings": {
|
||||
"changeName": {
|
||||
"title": "Organization name"
|
||||
},
|
||||
"deleteOrganization": {
|
||||
"confirmation": "Are you sure you want to delete your organization?",
|
||||
"description": "Permanently delete your organization. Once you delete your organization, there is no going back. To confirm, please enter your password below:",
|
||||
"submit": "Delete organization",
|
||||
"title": "Delete organization"
|
||||
},
|
||||
"logo": {
|
||||
"description": "Upload a logo for your organization.",
|
||||
"title": "Organization logo"
|
||||
},
|
||||
"members": {
|
||||
"activeMembers": "Active members",
|
||||
"description": "See all active members and the pending invites of your organization.",
|
||||
"invitations": {
|
||||
"empty": "You have not invited any members yet.",
|
||||
"expiresAt": "Expires at {date}",
|
||||
"invitationStatus": {
|
||||
"accepted": "Accepted",
|
||||
"canceled": "Canceled",
|
||||
"pending": "Pending",
|
||||
"rejected": "Rejected"
|
||||
},
|
||||
"revoke": "Revoke invitation",
|
||||
"resend": "Resend invitation"
|
||||
},
|
||||
"inviteMember": {
|
||||
"description": "To invite a new member, send them an invitation.",
|
||||
"email": "Email",
|
||||
"notifications": {
|
||||
"error": {
|
||||
"description": "We were unable to invite the member. Please try again later.",
|
||||
"title": "Could not invite member"
|
||||
},
|
||||
"success": {
|
||||
"description": "The member has been invited.",
|
||||
"title": "Member invited"
|
||||
}
|
||||
},
|
||||
"role": "Role",
|
||||
"submit": "Invite",
|
||||
"title": "Invite member"
|
||||
},
|
||||
"leaveOrganization": "Leave organization",
|
||||
"notifications": {
|
||||
"removeMember": {
|
||||
"error": {
|
||||
"description": "Could not remove the member from your organization. Please try again."
|
||||
},
|
||||
"loading": {
|
||||
"description": "Removing member from organization..."
|
||||
},
|
||||
"success": {
|
||||
"description": "The member has been successfully removed from your organization."
|
||||
}
|
||||
},
|
||||
"revokeInvitation": {
|
||||
"error": {
|
||||
"description": "The invitation could not be revoked. Please try again later."
|
||||
},
|
||||
"loading": {
|
||||
"description": "Revoking invitation..."
|
||||
},
|
||||
"success": {
|
||||
"description": "The invitation has been revoked."
|
||||
}
|
||||
},
|
||||
"updateMembership": {
|
||||
"error": {
|
||||
"description": "Could not update organization membership. Please try again."
|
||||
},
|
||||
"loading": {
|
||||
"description": "Updating membership..."
|
||||
},
|
||||
"success": {
|
||||
"description": "Membership was updated successfully"
|
||||
}
|
||||
},
|
||||
"resendInvitation": {
|
||||
"loading": {
|
||||
"description": "Resending invitation..."
|
||||
},
|
||||
"success": {
|
||||
"description": "Invitation sent"
|
||||
},
|
||||
"error": {
|
||||
"description": "Could not sent invitation. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pendingInvitations": "Pending invitations",
|
||||
"removeMember": "Remove member",
|
||||
"title": "Members"
|
||||
},
|
||||
"notifications": {
|
||||
"organizationDeleted": "Your organization has been deleted.",
|
||||
"organizationNameNotUpdated": "We were unable to update your organization name. Please try again later.",
|
||||
"organizationNameUpdated": "Your organization name has been updated.",
|
||||
"organizationNotDeleted": "We were unable to delete your organization. Please try again later."
|
||||
},
|
||||
"subtitle": "Manage the settings of the organization.",
|
||||
"title": "Organization",
|
||||
"dangerZone": {
|
||||
"title": "Danger zone"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"subtitle": "Welcome to the start page of this organization!"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"choosePlan": "Choose plan",
|
||||
"contactSales": "Contact sales",
|
||||
"description": "Choose the plan that works best for you.",
|
||||
"getStarted": "Get started",
|
||||
"month": "{count, plural, one {month} other {{count} months}}",
|
||||
"monthly": "Monthly",
|
||||
"products": {
|
||||
"basic": {
|
||||
"description": "Perfect to small teams.",
|
||||
"features": {
|
||||
"anotherFeature": "Another amazing feature",
|
||||
"limitedSupport": "Limited support"
|
||||
},
|
||||
"title": "Basic"
|
||||
},
|
||||
"enterprise": {
|
||||
"description": "Custom plan tailored to your requirements",
|
||||
"features": {
|
||||
"enterpriseSupport": "Enterprise support",
|
||||
"unlimitedProjects": "Unlimited projects"
|
||||
},
|
||||
"title": "Enterprise"
|
||||
},
|
||||
"free": {
|
||||
"description": "Start for free",
|
||||
"features": {
|
||||
"anotherFeature": "Another amazing feature",
|
||||
"limitedSupport": "Limited support"
|
||||
},
|
||||
"title": "Free"
|
||||
},
|
||||
"lifetime": {
|
||||
"description": "Buy once. Use forever.",
|
||||
"features": {
|
||||
"extendSupport": "Extended support",
|
||||
"noRecurringCosts": "No recurring costs"
|
||||
},
|
||||
"title": "Lifetime"
|
||||
},
|
||||
"pro": {
|
||||
"description": "Best for teams",
|
||||
"features": {
|
||||
"anotherFeature": "Another amazing feature",
|
||||
"fiveMembers": "Up to 5 members",
|
||||
"fullSupport": "Full support"
|
||||
},
|
||||
"title": "Pro"
|
||||
}
|
||||
},
|
||||
"purchase": "Purchase",
|
||||
"recommended": "Recommended",
|
||||
"subscribe": "Subscribe",
|
||||
"title": "Pricing",
|
||||
"trialPeriod": "{days} {days, plural, one {day} other {days}} free trial",
|
||||
"year": "{count, plural, one {year} other {{count} years}}",
|
||||
"yearly": "Yearly",
|
||||
"perSeat": "seat"
|
||||
},
|
||||
"settings": {
|
||||
"account": {
|
||||
"avatar": {
|
||||
"description": "To change your avatar click the picture in this block and select a file from your computer to upload.",
|
||||
"notifications": {
|
||||
"error": "Could not update avatar",
|
||||
"success": "Avatar was updated successfully"
|
||||
},
|
||||
"title": "Your avatar"
|
||||
},
|
||||
"changeEmail": {
|
||||
"description": "To change your email, enter the new email and hit save. You will have to confirm the new email before it will become active.",
|
||||
"notifications": {
|
||||
"error": "Could not update email",
|
||||
"success": "Email was updated successfully"
|
||||
},
|
||||
"title": "Your email"
|
||||
},
|
||||
"changeName": {
|
||||
"notifications": {
|
||||
"error": "Could not update name",
|
||||
"success": "Name was updated successfully"
|
||||
},
|
||||
"title": "Your name"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"confirmation": "Are you sure you want to delete your account?",
|
||||
"description": "Permanently delete your account. Once you delete your account, there is no going back. To confirm, please enter your password below:",
|
||||
"notifications": {
|
||||
"error": "Could not delete account",
|
||||
"success": "Account was deleted successfully"
|
||||
},
|
||||
"submit": "Delete account",
|
||||
"title": "Delete account"
|
||||
},
|
||||
"language": {
|
||||
"description": "To change the language of the app for your account, select a language from the list and click save.",
|
||||
"notifications": {
|
||||
"error": "Could not update language",
|
||||
"success": "Language was updated successfully"
|
||||
},
|
||||
"title": "Your language"
|
||||
},
|
||||
"security": {
|
||||
"activeSessions": {
|
||||
"description": "These are all the active sessions of your account. Click the X to end a specifc session.",
|
||||
"title": "Active sessions",
|
||||
"notifications": {
|
||||
"revokeSession": {
|
||||
"success": "Session revoked"
|
||||
}
|
||||
},
|
||||
"currentSession": "Current session"
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"notifications": {
|
||||
"error": "Could not update password",
|
||||
"success": "Password was updated successfully"
|
||||
},
|
||||
"title": "Your password"
|
||||
},
|
||||
"connectedAccounts": {
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"title": "Connected accounts"
|
||||
},
|
||||
"passkeys": {
|
||||
"description": "Use passkeys as a secure alternative to passwords.",
|
||||
"notifications": {
|
||||
"addPasskey": {
|
||||
"error": {
|
||||
"title": "Could not add passkey"
|
||||
},
|
||||
"success": {
|
||||
"title": "Passkey added"
|
||||
}
|
||||
},
|
||||
"deletePasskey": {
|
||||
"error": {
|
||||
"title": "Could not delete passkey"
|
||||
},
|
||||
"loading": {
|
||||
"title": "Deleting passkey..."
|
||||
},
|
||||
"success": {
|
||||
"title": "Passkey deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Passkeys"
|
||||
},
|
||||
"setPassword": {
|
||||
"description": "You have not set a password yet. To set one, you need to go through the password reset flow. Click the button below to send an email to reset your password and follow the instructions in the email.",
|
||||
"notifications": {
|
||||
"error": "Could not send link to set password. Please try again.",
|
||||
"success": "Check your inbox for the link to set your password."
|
||||
},
|
||||
"submit": "Set password",
|
||||
"title": "Your password"
|
||||
},
|
||||
"title": "Security",
|
||||
"twoFactor": {
|
||||
"title": "Two-factor authentication",
|
||||
"description": "Add an extra layer of security to your account.",
|
||||
"dialog": {
|
||||
"password": {
|
||||
"title": "Verify with password",
|
||||
"description": "Please verify your account by entering your password:",
|
||||
"label": "Your password:"
|
||||
},
|
||||
"totpUrl": {
|
||||
"description": "Use your preferred authenticator app and scan the QR code with it or enter the secret below manually to set up two-factor authentication.",
|
||||
"code": "Enter 6-digit code to verify the setup:",
|
||||
"title": "Enable two-factor authentication"
|
||||
}
|
||||
},
|
||||
"enable": "Enable two-factor authentication",
|
||||
"notifications": {
|
||||
"verify": {
|
||||
"success": {
|
||||
"title": "Two-factor authentication has been enabled successfully."
|
||||
}
|
||||
},
|
||||
"enable": {
|
||||
"error": {
|
||||
"title": "Could not verify your account with the provided password. Please try again."
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"success": {
|
||||
"title": "Two-factor authentication has been disabled successfully."
|
||||
}
|
||||
}
|
||||
},
|
||||
"disable": "Disable two-factor authentication",
|
||||
"enabled": "You have two-factor authentication enabled for your account."
|
||||
}
|
||||
},
|
||||
"subtitle": "Manage the settings of your personal account.",
|
||||
"title": "Account settings"
|
||||
},
|
||||
"billing": {
|
||||
"createCustomerPortal": {
|
||||
"label": "Manage billing",
|
||||
"notifications": {
|
||||
"error": {
|
||||
"title": "Could not create a customer portal session. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"activePlan": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"canceled": "Canceled",
|
||||
"expired": "Expired",
|
||||
"incomplete": "Incomplete",
|
||||
"past_due": "Past due",
|
||||
"paused": "Paused",
|
||||
"trialing": "Trialing",
|
||||
"unpaid": "Unpaid"
|
||||
},
|
||||
"title": "Your plan"
|
||||
},
|
||||
"changePlan": {
|
||||
"description": "Choose a plan to subscribe to.",
|
||||
"title": "Change your plan"
|
||||
},
|
||||
"title": "Billing"
|
||||
},
|
||||
"menu": {
|
||||
"account": {
|
||||
"billing": "Billing",
|
||||
"dangerZone": "Danger zone",
|
||||
"general": "General",
|
||||
"security": "Security",
|
||||
"title": "Account"
|
||||
},
|
||||
"organization": {
|
||||
"billing": "Billing",
|
||||
"general": "General",
|
||||
"members": "Members",
|
||||
"title": "Organization",
|
||||
"dangerZone": "Danger zone"
|
||||
}
|
||||
},
|
||||
"save": "Save"
|
||||
},
|
||||
"start": {
|
||||
"subtitle": "See the latest stats of your awesome business.",
|
||||
"welcome": "Welcome {name}!"
|
||||
},
|
||||
"zod": {
|
||||
"errors": {
|
||||
"invalid_arguments": "Invalid function arguments",
|
||||
"invalid_date": "Invalid date",
|
||||
"invalid_enum_value": "Invalid enum value. Expected {- options}, received '{received}'",
|
||||
"invalid_intersection_types": "Intersection results could not be merged",
|
||||
"invalid_literal": "Invalid literal value, expected {expected}",
|
||||
"invalid_return_type": "Invalid function return type",
|
||||
"invalid_string": {
|
||||
"cuid": "Invalid {validation}",
|
||||
"datetime": "Invalid {validation}",
|
||||
"email": "Invalid {validation}",
|
||||
"endsWith": "Invalid input: must end with \"{endsWith}\"",
|
||||
"regex": "Invalid",
|
||||
"startsWith": "Invalid input: must start with \"{startsWith}\"",
|
||||
"url": "Invalid {validation}",
|
||||
"uuid": "Invalid {validation}"
|
||||
},
|
||||
"invalid_type": "Expected {expected}, received {received}",
|
||||
"invalid_type_received_undefined": "Required",
|
||||
"invalid_union": "Invalid input",
|
||||
"invalid_union_discriminator": "Invalid discriminator value. Expected {- options}",
|
||||
"not_finite": "Number must be finite",
|
||||
"not_multiple_of": "Number must be a multiple of {multipleOf}",
|
||||
"too_big": {
|
||||
"array": {
|
||||
"exact": "Array must contain exactly {maximum} element(s)",
|
||||
"inclusive": "Array must contain at most {maximum} element(s)",
|
||||
"not_inclusive": "Array must contain less than {maximum} element(s)"
|
||||
},
|
||||
"date": {
|
||||
"exact": "Date must be exactly {- maximum, datetime}",
|
||||
"inclusive": "Date must be smaller than or equal to {- maximum, datetime}",
|
||||
"not_inclusive": "Date must be smaller than {- maximum, datetime}"
|
||||
},
|
||||
"number": {
|
||||
"exact": "Number must be exactly {maximum}",
|
||||
"inclusive": "Number must be less than or equal to {maximum}",
|
||||
"not_inclusive": "Number must be less than {maximum}"
|
||||
},
|
||||
"set": {
|
||||
"exact": "Invalid input",
|
||||
"inclusive": "Invalid input",
|
||||
"not_inclusive": "Invalid input"
|
||||
},
|
||||
"string": {
|
||||
"exact": "String must contain exactly {maximum} character(s)",
|
||||
"inclusive": "String must contain at most {maximum} character(s)",
|
||||
"not_inclusive": "String must contain under {maximum} character(s)"
|
||||
}
|
||||
},
|
||||
"too_small": {
|
||||
"array": {
|
||||
"exact": "Array must contain exactly {minimum} element(s)",
|
||||
"inclusive": "Array must contain at least {minimum} element(s)",
|
||||
"not_inclusive": "Array must contain more than {minimum} element(s)"
|
||||
},
|
||||
"date": {
|
||||
"exact": "Date must be exactly {- minimum, datetime}",
|
||||
"inclusive": "Date must be greater than or equal to {- minimum, datetime}",
|
||||
"not_inclusive": "Date must be greater than {- minimum, datetime}"
|
||||
},
|
||||
"number": {
|
||||
"exact": "Number must be exactly {minimum}",
|
||||
"inclusive": "Number must be greater than or equal to {minimum}",
|
||||
"not_inclusive": "Number must be greater than {minimum}"
|
||||
},
|
||||
"set": {
|
||||
"exact": "Invalid input",
|
||||
"inclusive": "Invalid input",
|
||||
"not_inclusive": "Invalid input"
|
||||
},
|
||||
"string": {
|
||||
"exact": "String must contain exactly {minimum} character(s)",
|
||||
"inclusive": "String must contain at least {minimum} character(s)",
|
||||
"not_inclusive": "String must contain over {minimum} character(s)"
|
||||
}
|
||||
},
|
||||
"unrecognized_keys": "Unrecognized key(s) in object: {- keys}"
|
||||
},
|
||||
"types": {
|
||||
"array": "array",
|
||||
"bigint": "bigint",
|
||||
"boolean": "boolean",
|
||||
"date": "date",
|
||||
"float": "float",
|
||||
"function": "function",
|
||||
"integer": "integer",
|
||||
"map": "map",
|
||||
"nan": "nan",
|
||||
"never": "never",
|
||||
"null": "null",
|
||||
"number": "number",
|
||||
"object": "object",
|
||||
"promise": "promise",
|
||||
"set": "set",
|
||||
"string": "string",
|
||||
"symbol": "symbol",
|
||||
"undefined": "undefined",
|
||||
"unknown": "unknown",
|
||||
"void": "void"
|
||||
},
|
||||
"validations": {
|
||||
"cuid": "cuid",
|
||||
"cuid2": "cuid2",
|
||||
"datetime": "datetime",
|
||||
"email": "email",
|
||||
"emoji": "emoji",
|
||||
"ip": "up",
|
||||
"regex": "regex",
|
||||
"ulid": "ulid",
|
||||
"url": "url",
|
||||
"uuid": "uuid"
|
||||
}
|
||||
},
|
||||
"choosePlan": {
|
||||
"title": "Choose your plan",
|
||||
"description": "To continue, please select a plan."
|
||||
}
|
||||
}
|
||||
5
packages/i18n/tsconfig.json
Normal file
5
packages/i18n/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
6
packages/i18n/types.ts
Normal file
6
packages/i18n/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { config } from "@repo/config";
|
||||
import type messages from "./translations/en.json";
|
||||
|
||||
export type Messages = typeof messages;
|
||||
|
||||
export type Locale = keyof (typeof config)["i18n"]["locales"];
|
||||
3
packages/logs/biome.json
Normal file
3
packages/logs/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
1
packages/logs/index.ts
Normal file
1
packages/logs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./lib/logger";
|
||||
7
packages/logs/lib/logger.ts
Normal file
7
packages/logs/lib/logger.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createConsola } from "consola";
|
||||
|
||||
export const logger = createConsola({
|
||||
formatOptions: {
|
||||
date: false,
|
||||
},
|
||||
});
|
||||
18
packages/logs/package.json
Normal file
18
packages/logs/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@repo/config": "workspace:*",
|
||||
"consola": "^3.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.30"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"name": "@repo/logs",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"types": "./**/.tsx",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
5
packages/logs/tsconfig.json
Normal file
5
packages/logs/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
3
packages/mail/.gitignore
vendored
Normal file
3
packages/mail/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.vercel
|
||||
.react-email
|
||||
out
|
||||
3
packages/mail/biome.json
Normal file
3
packages/mail/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
48
packages/mail/emails/EmailVerification.tsx
Normal file
48
packages/mail/emails/EmailVerification.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Link, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { createTranslator } from "use-intl/core";
|
||||
import PrimaryButton from "../src/components/PrimaryButton";
|
||||
import Wrapper from "../src/components/Wrapper";
|
||||
import { defaultLocale, defaultTranslations } from "../src/util/translations";
|
||||
import type { BaseMailProps } from "../types";
|
||||
|
||||
export function EmailVerification({
|
||||
url,
|
||||
name,
|
||||
locale,
|
||||
translations,
|
||||
}: {
|
||||
url: string;
|
||||
name: string;
|
||||
} & BaseMailProps) {
|
||||
const t = createTranslator({
|
||||
locale,
|
||||
messages: translations,
|
||||
});
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Text>{t("mail.emailVerification.body", { name })}</Text>
|
||||
|
||||
<PrimaryButton href={url}>
|
||||
{t("mail.emailVerification.confirmEmail")} →
|
||||
</PrimaryButton>
|
||||
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("mail.common.openLinkInBrowser")}
|
||||
<Link href={url} className="break-all">
|
||||
{url}
|
||||
</Link>
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
EmailVerification.PreviewProps = {
|
||||
locale: defaultLocale,
|
||||
translations: defaultTranslations,
|
||||
url: "#",
|
||||
name: "John Doe",
|
||||
};
|
||||
|
||||
export default EmailVerification;
|
||||
46
packages/mail/emails/ForgotPassword.tsx
Normal file
46
packages/mail/emails/ForgotPassword.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Link, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { createTranslator } from "use-intl/core";
|
||||
import PrimaryButton from "../src/components/PrimaryButton";
|
||||
import Wrapper from "../src/components/Wrapper";
|
||||
import { defaultLocale } from "../src/util/translations";
|
||||
import { defaultTranslations } from "../src/util/translations";
|
||||
import type { BaseMailProps } from "../types";
|
||||
|
||||
export function ForgotPassword({
|
||||
url,
|
||||
locale,
|
||||
translations,
|
||||
}: {
|
||||
url: string;
|
||||
name: string;
|
||||
} & BaseMailProps) {
|
||||
const t = createTranslator({
|
||||
locale,
|
||||
messages: translations,
|
||||
});
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Text>{t("mail.forgotPassword.body")}</Text>
|
||||
|
||||
<PrimaryButton href={url}>
|
||||
{t("mail.forgotPassword.resetPassword")} →
|
||||
</PrimaryButton>
|
||||
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("mail.common.openLinkInBrowser")}
|
||||
<Link href={url}>{url}</Link>
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ForgotPassword.PreviewProps = {
|
||||
locale: defaultLocale,
|
||||
translations: defaultTranslations,
|
||||
url: "#",
|
||||
name: "John Doe",
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
46
packages/mail/emails/MagicLink.tsx
Normal file
46
packages/mail/emails/MagicLink.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Link, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { createTranslator } from "use-intl/core";
|
||||
import PrimaryButton from "../src/components/PrimaryButton";
|
||||
import Wrapper from "../src/components/Wrapper";
|
||||
import { defaultTranslations } from "../src/util/translations";
|
||||
import { defaultLocale } from "../src/util/translations";
|
||||
import type { BaseMailProps } from "../types";
|
||||
|
||||
export function MagicLink({
|
||||
url,
|
||||
locale,
|
||||
translations,
|
||||
}: {
|
||||
url: string;
|
||||
} & BaseMailProps) {
|
||||
const t = createTranslator({
|
||||
locale,
|
||||
messages: translations,
|
||||
});
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Text>{t("mail.magicLink.body")}</Text>
|
||||
|
||||
<Text>{t("mail.common.useLink")}</Text>
|
||||
|
||||
<PrimaryButton href={url}>
|
||||
{t("mail.magicLink.login")} →
|
||||
</PrimaryButton>
|
||||
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("mail.common.openLinkInBrowser")}
|
||||
<Link href={url}>{url}</Link>
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
MagicLink.PreviewProps = {
|
||||
locale: defaultLocale,
|
||||
translations: defaultTranslations,
|
||||
url: "#",
|
||||
};
|
||||
|
||||
export default MagicLink;
|
||||
58
packages/mail/emails/NewUser.tsx
Normal file
58
packages/mail/emails/NewUser.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Link, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { createTranslator } from "use-intl/core";
|
||||
import PrimaryButton from "../src/components/PrimaryButton";
|
||||
import Wrapper from "../src/components/Wrapper";
|
||||
import { defaultTranslations } from "../src/util/translations";
|
||||
import { defaultLocale } from "../src/util/translations";
|
||||
import type { BaseMailProps } from "../types";
|
||||
|
||||
export function NewUser({
|
||||
url,
|
||||
name,
|
||||
otp,
|
||||
locale,
|
||||
translations,
|
||||
}: {
|
||||
url: string;
|
||||
name: string;
|
||||
otp: string;
|
||||
} & BaseMailProps) {
|
||||
const t = createTranslator({
|
||||
locale,
|
||||
messages: translations,
|
||||
});
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Text>{t("mail.newUser.body", { name })}</Text>
|
||||
|
||||
<Text>
|
||||
{t("mail.common.otp")}
|
||||
<br />
|
||||
<strong className="font-bold text-2xl">{otp}</strong>
|
||||
</Text>
|
||||
|
||||
<Text>{t("mail.common.useLink")}</Text>
|
||||
|
||||
<PrimaryButton href={url}>
|
||||
{t("mail.newUser.confirmEmail")} →
|
||||
</PrimaryButton>
|
||||
|
||||
<Text className="text-muted-foreground text-sm">
|
||||
{t("mail.common.openLinkInBrowser")}
|
||||
<Link href={url}>{url}</Link>
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
NewUser.PreviewProps = {
|
||||
locale: defaultLocale,
|
||||
translations: defaultTranslations,
|
||||
url: "#",
|
||||
name: "John Doe",
|
||||
otp: "123456",
|
||||
};
|
||||
|
||||
export default NewUser;
|
||||
30
packages/mail/emails/NewsletterSignup.tsx
Normal file
30
packages/mail/emails/NewsletterSignup.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { createTranslator } from "use-intl/core";
|
||||
import Wrapper from "../src/components/Wrapper";
|
||||
import { defaultTranslations } from "../src/util/translations";
|
||||
import { defaultLocale } from "../src/util/translations";
|
||||
import type { BaseMailProps } from "../types";
|
||||
|
||||
export function NewsletterSignup({ locale, translations }: BaseMailProps) {
|
||||
const t = createTranslator({
|
||||
locale,
|
||||
messages: translations,
|
||||
});
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Heading className="text-xl">
|
||||
{t("mail.newsletterSignup.subject")}
|
||||
</Heading>
|
||||
<Text>{t("mail.newsletterSignup.body")}</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
NewsletterSignup.PreviewProps = {
|
||||
locale: defaultLocale,
|
||||
translations: defaultTranslations,
|
||||
};
|
||||
|
||||
export default NewsletterSignup;
|
||||
55
packages/mail/emails/OrganizationInvitation.tsx
Normal file
55
packages/mail/emails/OrganizationInvitation.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Heading, Link, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import { createTranslator } from "use-intl/core";
|
||||
import PrimaryButton from "../src/components/PrimaryButton";
|
||||
import Wrapper from "../src/components/Wrapper";
|
||||
import { defaultLocale } from "../src/util/translations";
|
||||
import { defaultTranslations } from "../src/util/translations";
|
||||
import type { BaseMailProps } from "../types";
|
||||
|
||||
export function OrganizationInvitation({
|
||||
url,
|
||||
organizationName,
|
||||
locale,
|
||||
translations,
|
||||
}: {
|
||||
url: string;
|
||||
organizationName: string;
|
||||
} & BaseMailProps) {
|
||||
const t = createTranslator({
|
||||
locale,
|
||||
messages: translations,
|
||||
});
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Heading className="text-xl">
|
||||
{t.markup("mail.organizationInvitation.headline", {
|
||||
organizationName,
|
||||
strong: (chunks) => `<strong>${chunks}</strong>`,
|
||||
})}
|
||||
</Heading>
|
||||
<Text>
|
||||
{t("mail.organizationInvitation.body", { organizationName })}
|
||||
</Text>
|
||||
|
||||
<PrimaryButton href={url}>
|
||||
{t("mail.organizationInvitation.join")}
|
||||
</PrimaryButton>
|
||||
|
||||
<Text className="mt-4 text-muted-foreground text-sm">
|
||||
{t("mail.common.openLinkInBrowser")}
|
||||
<Link href={url}>{url}</Link>
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
OrganizationInvitation.PreviewProps = {
|
||||
locale: defaultLocale,
|
||||
translations: defaultTranslations,
|
||||
url: "#",
|
||||
organizationName: "Acme",
|
||||
};
|
||||
|
||||
export default OrganizationInvitation;
|
||||
15
packages/mail/emails/index.ts
Normal file
15
packages/mail/emails/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { EmailVerification } from "../emails/EmailVerification";
|
||||
import { ForgotPassword } from "../emails/ForgotPassword";
|
||||
import { MagicLink } from "../emails/MagicLink";
|
||||
import { NewUser } from "../emails/NewUser";
|
||||
import { NewsletterSignup } from "../emails/NewsletterSignup";
|
||||
import { OrganizationInvitation } from "../emails/OrganizationInvitation";
|
||||
|
||||
export const mailTemplates = {
|
||||
magicLink: MagicLink,
|
||||
forgotPassword: ForgotPassword,
|
||||
newUser: NewUser,
|
||||
newsletterSignup: NewsletterSignup,
|
||||
organizationInvitation: OrganizationInvitation,
|
||||
emailVerification: EmailVerification,
|
||||
} as const;
|
||||
5
packages/mail/global.d.ts
vendored
Normal file
5
packages/mail/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { Messages } from "@repo/i18n/types";
|
||||
|
||||
declare global {
|
||||
interface IntlMessages extends Messages {}
|
||||
}
|
||||
1
packages/mail/index.ts
Normal file
1
packages/mail/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { sendEmail } from "./src/util/send";
|
||||
32
packages/mail/package.json
Normal file
32
packages/mail/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.41",
|
||||
"@react-email/render": "^1.1.2",
|
||||
"@repo/config": "workspace:*",
|
||||
"@repo/i18n": "workspace:*",
|
||||
"@repo/logs": "workspace:*",
|
||||
"next-intl": "4.1.0",
|
||||
"nodemailer": "^7.0.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-email": "^4.0.16",
|
||||
"use-intl": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/tsconfig": "workspace:*",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@types/node": "22.15.30",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "19.1.6"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"name": "@repo/mail",
|
||||
"scripts": {
|
||||
"export": "email export",
|
||||
"preview": "email dev --port 3005",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"types": "./index.ts",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
32
packages/mail/src/components/Logo.tsx
Normal file
32
packages/mail/src/components/Logo.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from "react";
|
||||
|
||||
export function Logo({
|
||||
withLabel = true,
|
||||
}: {
|
||||
withLabel?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center font-semibold text-primary leading-none">
|
||||
<svg className="h-12 w-12" viewBox="0 0 734 635">
|
||||
<title>acme</title>
|
||||
<path
|
||||
opacity="0.2"
|
||||
d="M282.102 232.435C328.904 205.42 404.785 205.42 451.588 232.435L697.946 374.634C744.748 401.648 744.748 445.447 697.946 472.462L451.588 614.661C404.785 641.676 328.904 641.676 282.102 614.661L35.7432 472.462C-11.059 445.447 -11.0589 401.648 35.7432 374.634L282.102 232.435Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M282.102 126.674C328.904 99.66 404.785 99.66 451.588 126.674L697.946 268.874C744.748 295.888 744.748 339.687 697.946 366.702L451.588 508.901C404.785 535.915 328.904 535.915 282.102 508.901L35.7432 366.702C-11.059 339.687 -11.0589 295.888 35.7432 268.874L282.102 126.674Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M451.588 20.9141C404.785 -6.10027 328.904 -6.1003 282.102 20.9141L35.7432 163.113C-11.0589 190.128 -11.059 233.927 35.7432 260.941L282.102 403.141C328.904 430.155 404.785 430.155 451.588 403.141L697.946 260.941C744.748 233.927 744.748 190.128 697.946 163.113L451.588 20.9141ZM497.704 114.921C499.134 115.855 500.121 117.04 500.545 118.332C505.138 132.238 505.138 143.12 505.072 154.003C505.072 198.349 468.453 225.167 420.48 245.161V290.25C420.485 294.097 418.849 297.868 415.755 301.141C412.662 304.413 408.233 307.058 402.967 308.777L337.739 330.105C335.32 330.893 332.634 331.263 329.935 331.181C327.236 331.1 324.613 330.569 322.316 329.64C320.019 328.71 318.124 327.412 316.809 325.87C315.495 324.327 314.806 322.591 314.806 320.825V275.982L299.957 285.686C297.993 286.969 295.661 287.987 293.095 288.682C290.529 289.377 287.779 289.734 285.001 289.734C282.223 289.734 279.473 289.377 276.907 288.682C274.341 287.987 272.009 286.969 270.045 285.686L236.407 263.7C232.442 261.109 230.214 257.594 230.214 253.93C230.214 250.265 232.442 246.751 236.407 244.159L251.257 234.456H182.678C179.975 234.457 177.316 234.006 174.955 233.147C172.593 232.288 170.607 231.049 169.184 229.547C167.761 228.046 166.949 226.331 166.825 224.567C166.701 222.803 167.269 221.047 168.475 219.466L201.136 176.8C203.771 173.364 207.817 170.475 212.82 168.455C217.823 166.435 223.587 165.364 229.468 165.36H298.331C328.857 133.922 369.765 110.084 437.967 110.084C454.555 110.084 471.202 110.084 492.483 113.064C494.46 113.341 496.273 113.986 497.704 114.921ZM405.86 179.723C410.207 181.621 415.318 182.634 420.546 182.634C427.557 182.634 434.281 180.814 439.239 177.575C444.196 174.335 446.981 169.942 446.981 165.36C446.981 161.944 445.431 158.604 442.526 155.763C439.622 152.923 435.493 150.709 430.663 149.401C425.832 148.094 420.517 147.752 415.389 148.418C410.261 149.085 405.551 150.73 401.854 153.146C398.157 155.562 395.639 158.64 394.619 161.99C393.599 165.341 394.123 168.814 396.124 171.971C398.124 175.127 401.513 177.825 405.86 179.723Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{withLabel && <span className="ml-3 text-xl">acme</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
18
packages/mail/src/components/PrimaryButton.tsx
Normal file
18
packages/mail/src/components/PrimaryButton.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Button } from "@react-email/components";
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
|
||||
export default function PrimaryButton({
|
||||
href,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
href: string;
|
||||
}>) {
|
||||
return (
|
||||
<Button
|
||||
href={href}
|
||||
className="rounded-full bg-primary px-4 py-2 text-lg text-primary-foreground"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
57
packages/mail/src/components/Wrapper.tsx
Normal file
57
packages/mail/src/components/Wrapper.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
Container,
|
||||
Font,
|
||||
Head,
|
||||
Html,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
export default function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<Font
|
||||
fontFamily="Inter"
|
||||
fallbackFontFamily="Arial"
|
||||
fontWeight={400}
|
||||
fontStyle="normal"
|
||||
/>
|
||||
</Head>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "#e3ebf6",
|
||||
background: "#fafafe",
|
||||
foreground: "#292b35",
|
||||
primary: {
|
||||
DEFAULT: "#4e6df5",
|
||||
foreground: "#f6f7f9",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "#292b35",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "#ffffff",
|
||||
foreground: "#292b35",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="bg-background p-4">
|
||||
<Container className="rounded-lg bg-card p-6 text-card-foreground">
|
||||
<Logo />
|
||||
{children}
|
||||
</Container>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
10
packages/mail/src/provider/console.ts
Normal file
10
packages/mail/src/provider/console.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { logger } from "@repo/logs";
|
||||
import type { SendEmailHandler } from "../../types";
|
||||
|
||||
export const send: SendEmailHandler = async ({ to, subject, text }) => {
|
||||
let formattedOutput = `Sending email to ${to} with subject ${subject}\n\n`;
|
||||
|
||||
formattedOutput += `Text: ${text}\n\n`;
|
||||
|
||||
logger.log(formattedOutput);
|
||||
};
|
||||
9
packages/mail/src/provider/custom.ts
Normal file
9
packages/mail/src/provider/custom.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { config } from "@repo/config";
|
||||
import type { SendEmailHandler } from "../../types";
|
||||
|
||||
const { from } = config.mails;
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: This is to understand the available parameters
|
||||
export const send: SendEmailHandler = async ({ to, subject, text, html }) => {
|
||||
// handle your custom email sending logic here
|
||||
};
|
||||
1
packages/mail/src/provider/index.ts
Normal file
1
packages/mail/src/provider/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./plunk";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue