diff --git a/.github/workflows/production-api.yml b/.github/workflows/production-api.yml deleted file mode 100644 index 848e4115..00000000 --- a/.github/workflows/production-api.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: 🌟 Production Deployment - API - -on: - push: - branches: - - main - paths: - - apps/api/** -jobs: - deploy-production: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - name: πŸ“¦ Install dependencies - run: bun install - - name: πŸ”¦ Run type checking - run: bun turbo typecheck --filter=@languine/api - - name: πŸ”¬ Run linting - run: bun turbo lint --filter=@languine/api - - name: πŸ§ͺ Run unit tests - run: bun turbo test --filter=@languine/api - - name: πŸ—ƒοΈ Apply database migrations - uses: cloudflare/wrangler-action@v3 - with: - packageManager: bun - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - workingDirectory: "apps/api" - wranglerVersion: "latest" - command: d1 migrations apply languine-production --env production --remote - - name: πŸš€ Deploy Project Artifacts to Cloudflare - uses: cloudflare/wrangler-action@v3 - with: - packageManager: bun - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - workingDirectory: "apps/api" - wranglerVersion: "latest" - command: deploy --minify src/index.ts --env production \ No newline at end of file diff --git a/apps/api/.dev.vars-example b/apps/api/.dev.vars-example deleted file mode 100644 index 51205019..00000000 --- a/apps/api/.dev.vars-example +++ /dev/null @@ -1,8 +0,0 @@ -RESEND_API_KEY= -BETTER_AUTH_SECRET="" -BETTER_AUTH_TRUSTED_ORIGINS="," -BETTER_AUTH_BASE_URL=http://localhost:3000 -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= \ No newline at end of file diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts deleted file mode 100644 index b60fea5a..00000000 --- a/apps/api/drizzle.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { Config } from "drizzle-kit"; - -const getLocalD1 = (): string => { - const wranglerDir = path.resolve(".wrangler"); - - try { - const files = fs.readdirSync(wranglerDir, { - encoding: "utf-8", - recursive: true, - }); - - const dbFile = files.find((f) => f.endsWith(".sqlite")); - - if (!dbFile) { - throw new Error(`No SQLite database found in ${wranglerDir}`); - } - - return path.resolve(wranglerDir, dbFile); - } catch { - return path.resolve(wranglerDir, "default.sqlite"); - } -}; - -export default { - dialect: "sqlite", - schema: "./src/db/schema.ts", - out: "./drizzle", - ...(!process.env.DEV_MODE - ? { - driver: "d1-http", - } - : {}), - ...(process.env.DEV_MODE - ? { - dbCredentials: { - url: getLocalD1(), - }, - } - : {}), -} satisfies Config; diff --git a/apps/api/locales/en.json b/apps/api/locales/en.json deleted file mode 100644 index 2d1ddddc..00000000 --- a/apps/api/locales/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "email": { - "welcome": { - "preview": "Welcome to Languine - Your Automated Localization Solution", - "greeting": "Hi %{name},", - "intro": "Thank you for signing up for Languine! We're excited to help you automate your application's localization.", - "feature1": "Seamless integration with your existing codebase", - "feature2": "Automated translation workflows", - "feature3": "Context-aware translations", - "feature4": "Translation management dashboard", - "support": "If you have any questions, feel free to reach out to us at %{email}", - "cta": { - "automate": "Start Automating", - "docs": "Read the Docs" - } - } - } -} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 7443c1e9..00000000 --- a/apps/api/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@languine/api", - "main": "src/index.ts", - "types": "src/index.ts", - "scripts": { - "dev": "DEV_MODE=true wrangler dev --port 3002", - "email:dev": "email dev --port 3003 --dir src/emails/templates", - "deploy": "wrangler deploy --minify", - "typecheck": "tsc --noEmit", - "clean": "rm -rf .turbo node_modules", - "lint": "biome check .", - "format": "biome format --write .", - "generate": "drizzle-kit generate", - "drizzle:up": "drizzle-kit up", - "amend": "drizzle-kit drop && drizzle-kit generate", - "studio": "DEV_MODE=true drizzle-kit studio" - }, - "dependencies": { - "@hono/zod-validator": "^0.4.2", - "@paralleldrive/cuid2": "^2.2.2", - "@react-email/components": "0.0.31", - "@react-email/font": "^0.0.9", - "@scalar/hono-api-reference": "^0.5.165", - "better-auth": "^1.1.10", - "drizzle-orm": "^0.38.3", - "hono": "4.6.15", - "hono-openapi": "^0.3.1", - "kysely": "^0.27.5", - "kysely-d1": "^0.3.0", - "react": "19.0.0", - "react-dom": "19.0.0", - "resend": "^4.0.1", - "slugify": "^1.6.6", - "zod": "^3.24.1", - "zod-openapi": "^4.2.2" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20241230.0", - "@libsql/client": "^0.14.0", - "@types/react": "19.0.3", - "@types/react-dom": "19.0.2", - "add": "2.0.6", - "bun": "1.1.42", - "drizzle-kit": "^0.30.1", - "react-email": "3.0.4", - "wrangler": "^3.99.0" - } -} \ No newline at end of file diff --git a/apps/api/src/bindings.d.ts b/apps/api/src/bindings.d.ts deleted file mode 100644 index ce0cc921..00000000 --- a/apps/api/src/bindings.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { auth } from "@/auth"; -import type { Env } from "hono"; - -type Environment = Env & { - Bindings: { - DB: D1Database; - ENV_TYPE: "dev" | "prod" | "stage"; - RESEND_API_KEY: string; - BETTER_AUTH_SECRET: string; - BETTER_AUTH_TRUSTED_ORIGINS: string; - }; - Variables: { - user: typeof auth.$Infer.Session.user | null; - session: typeof auth.$Infer.Session.session | null; - }; -}; diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts deleted file mode 100644 index 839897b0..00000000 --- a/apps/api/src/db/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { drizzle } from "drizzle-orm/d1"; -import * as schema from "./schema"; - -export const db = (env: D1Database) => drizzle(env, { schema }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index 93ff5722..00000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Hono } from "@/lib/app"; -import { apiReference } from "@scalar/hono-api-reference"; -import { openAPISpecs } from "hono-openapi"; -import { cors } from "hono/cors"; -import { setupAuth } from "./lib/auth"; -import { sessionMiddleware } from "./middleware"; -import feedbackRouter from "./routes/feedback"; -import fineTuneRouter from "./routes/fine-tune"; -import projectsRouter from "./routes/projects"; -import teamsRouter from "./routes/teams"; -import telemetryRouter from "./routes/telemetry"; -import translateRouter from "./routes/translate"; -import usersRouter from "./routes/users"; - -const app = new Hono(); - -app.use( - "*", - cors({ - origin: ["http://localhost:3000", "https://languine.ai"], - allowHeaders: ["Content-Type", "Authorization", "credentials"], - allowMethods: ["POST", "GET", "OPTIONS"], - exposeHeaders: ["Content-Length"], - maxAge: 600, - credentials: true, - }), -); - -app.on(["POST", "GET"], "/api/auth/**", (c) => { - return setupAuth(c).handler(c.req.raw); -}); - -app.use("*", sessionMiddleware); - -const appRoutes = app - .route("/telemetry", telemetryRouter) - .route("/fine-tune", fineTuneRouter) - .route("/feedback", feedbackRouter) - .route("/translate", translateRouter) - .route("/users", usersRouter) - .route("/projects", projectsRouter) - .route("/teams", teamsRouter); - -app.get( - "/openapi", - openAPISpecs(app, { - documentation: { - info: { - title: "Languine API", - version: "1.0.0", - description: "API for Languine", - }, - servers: [ - { - url: "http://localhost:3002", - description: "Local server", - }, - { - url: "https://api.languine.ai", - description: "Production server", - }, - ], - }, - }), -); - -app.get( - "/", - apiReference({ - theme: "saturn", - spec: { url: "/openapi" }, - }), -); - -export type AppType = typeof appRoutes; - -export default app; diff --git a/apps/api/src/lib/app.ts b/apps/api/src/lib/app.ts deleted file mode 100644 index c40fcfce..00000000 --- a/apps/api/src/lib/app.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Hono as Base } from "hono"; -import type { Environment } from "../bindings.js"; - -export const Hono = Base; diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts deleted file mode 100644 index 4acf730a..00000000 --- a/apps/api/src/lib/auth.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { db } from "@/db"; -import { members, organizations, projects, sessions } from "@/db/schema"; -import WelcomeEmail from "@/emails/templates/welcome"; -import { createId } from "@paralleldrive/cuid2"; -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { organization } from "better-auth/plugins"; -import { eq } from "drizzle-orm"; -import type { Context } from "hono"; -import { Resend } from "resend"; -import slugify from "slugify"; - -export const setupAuth = (c: Context) => { - return betterAuth({ - database: drizzleAdapter(db(c.env.DB), { - provider: "sqlite", - usePlural: true, - }), - secret: c.env.BETTER_AUTH_SECRET, - baseURL: c.env.BETTER_AUTH_BASE_URL, - trustedOrigins: c.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","), - socialProviders: { - github: { - clientId: c.env.GITHUB_CLIENT_ID, - clientSecret: c.env.GITHUB_CLIENT_SECRET, - }, - google: { - clientId: c.env.GOOGLE_CLIENT_ID, - clientSecret: c.env.GOOGLE_CLIENT_SECRET, - }, - }, - databaseHooks: { - user: { - create: { - after: async (user) => { - const database = db(c.env.DB); - - // Create default organization for new user - const org = await database - .insert(organizations) - .values({ - name: user.name, - slug: `${slugify(user.name, { lower: true })}-${createId().slice(0, 8)}`, - }) - .returning() - .get(); - - // Add user as member of organization - await database.insert(members).values({ - userId: user.id, - organizationId: org.id, - role: "owner", - }); - - // Create default project for new organization - await database.insert(projects).values({ - name: "Default", - organizationId: org.id, - slug: "default", - }); - - // Set active organization for new user's session - await database - .update(sessions) - .set({ activeOrganizationId: org.id }) - .where(eq(sessions.userId, user.id)); - - // Send welcome email to new user - try { - await new Resend(c.env.RESEND_API_KEY).emails.send({ - from: "Languine ", - to: user.email, - subject: "Welcome to Languine", - react: WelcomeEmail({ name: user.name }), - }); - } catch (error) { - console.error("Error sending welcome email", error); - } - }, - }, - }, - session: { - create: { - before: async (session) => { - const database = db(c.env.DB); - - const org = await database - .select() - .from(members) - .where(eq(members.userId, session.userId)) - .leftJoin( - organizations, - eq(organizations.id, members.organizationId), - ) - .limit(1) - .get(); - - return { - data: { - ...session, - activeOrganizationId: org?.organizations?.id, - }, - }; - }, - }, - }, - }, - session: { - cookieCache: { - enabled: true, - maxAge: 5 * 60, - }, - }, - advanced: { - crossSubDomainCookies: { - enabled: !c.env.BETTER_AUTH_BASE_URL?.includes("localhost"), - domain: ".languine.ai", - }, - }, - plugins: [organization()], - }); -}; diff --git a/apps/api/src/lib/envs.ts b/apps/api/src/lib/envs.ts deleted file mode 100644 index 76343fcc..00000000 --- a/apps/api/src/lib/envs.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Environment } from "@/bindings"; -import { getContext } from "hono/context-storage"; - -export const getEnvs = () => { - return getContext(); -}; - -export function getAppUrl() { - if (process.env.NODE_ENV === "development") { - return "http://localhost:3000"; - } - - return "https://languine.ai"; -} diff --git a/apps/api/src/middleware.ts b/apps/api/src/middleware.ts deleted file mode 100644 index 974a4f71..00000000 --- a/apps/api/src/middleware.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { createMiddleware } from "hono/factory"; -import { setupAuth } from "./lib/auth"; - -export const sessionMiddleware = createMiddleware(async (c, next) => { - const auth = setupAuth(c); - const headers = new Headers(c.req.raw.headers); - - // Try to get session from cookie first - const session = await auth.api.getSession({ headers }); - - if (session) { - c.set("user", session.user); - - return next(); - } - - // Try user API key from Authorization header (Used in the CLI) - const userApiKey = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (userApiKey) { - const database = db(c.env.DB); - const user = await database - .select() - .from(users) - .where(eq(users.apiKey, userApiKey)) - .get(); - - if (user) { - c.set("user", user); - - return next(); - } - } - - // If no valid session or API key, return unauthorized - return c.json({ error: "Unauthorized" }, 401); -}); diff --git a/apps/api/src/routes/feedback/index.ts b/apps/api/src/routes/feedback/index.ts deleted file mode 100644 index 13f460ec..00000000 --- a/apps/api/src/routes/feedback/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Hono } from "@/lib/app"; -import { describeRoute } from "hono-openapi"; -import { resolver, validator as zValidator } from "hono-openapi/zod"; -import { bodySchema, responseSchema } from "./schema"; - -const app = new Hono().post( - "/", - describeRoute({ - description: "Submit feedback", - responses: { - 200: { - description: "Successful feedback submission", - content: { - "application/json": { - schema: resolver(responseSchema), - }, - }, - }, - }, - }), - zValidator("json", bodySchema), - (c) => { - return c.json({ data: "Feedback received!" }); - }, -); - -export default app; diff --git a/apps/api/src/routes/feedback/schema.ts b/apps/api/src/routes/feedback/schema.ts deleted file mode 100644 index 87a0af5c..00000000 --- a/apps/api/src/routes/feedback/schema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import z from "zod"; -import "zod-openapi/extend"; - -export const bodySchema = z - .object({ - source: z - .string() - .openapi({ example: "en", description: "Source language code" }), - target: z - .string() - .openapi({ example: "es", description: "Target language code" }), - input: z - .string() - .openapi({ example: "Hello world", description: "Original text" }), - translation: z - .string() - .openapi({ example: "Hola mundo", description: "Machine translation" }), - suggestion: z - .string() - .optional() - .openapi({ example: "Β‘Hola mundo!", description: "User suggestion" }), - comment: z - .string() - .optional() - .openapi({ example: "Too formal", description: "Additional comments" }), - }) - .openapi({ ref: "Body" }); - -export const responseSchema = z - .string() - .openapi({ example: "Feedback received!" }); diff --git a/apps/api/src/routes/fine-tune/index.ts b/apps/api/src/routes/fine-tune/index.ts deleted file mode 100644 index 53026928..00000000 --- a/apps/api/src/routes/fine-tune/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Hono } from "@/lib/app"; -import { describeRoute } from "hono-openapi"; -import { resolver, validator as zValidator } from "hono-openapi/zod"; -import { fineTuneBodySchema, fineTuneResponseSchema } from "./schema"; - -const app = new Hono() - .post( - "/", - describeRoute({ - description: "Fine tune a model", - responses: { - 200: { - description: "Successful fine tune response", - content: { - "application/json": { - schema: resolver(fineTuneResponseSchema), - }, - }, - }, - }, - }), - zValidator("json", fineTuneBodySchema), - (c) => { - return c.json({ data: "Fine tuned model!" }); - }, - ) - .get( - "/:id", - describeRoute({ - description: "Get fine tune status", - responses: { - 200: { - description: "Successfully retrieved fine tune status", - content: { - "application/json": { - schema: resolver(fineTuneResponseSchema), - }, - }, - }, - 404: { - description: "Fine tune not found", - content: { - "application/json": { - schema: resolver(fineTuneResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const id = c.req.param("id"); - return c.json({ data: `Fine tune ${id} status` }); - }, - ); - -export default app; diff --git a/apps/api/src/routes/fine-tune/schema.ts b/apps/api/src/routes/fine-tune/schema.ts deleted file mode 100644 index 5e840b8b..00000000 --- a/apps/api/src/routes/fine-tune/schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -import z from "zod"; -import "zod-openapi/extend"; - -export const fineTuneBodySchema = z - .object({ - instructions: z.string().openapi({ - description: "Instructions text to fine-tune the model with", - example: - "You are a helpful translator assistant. You maintain a professional yet approachable tone, and excel at preserving both meaning and cultural context when translating between languages. You provide clear explanations for idiomatic expressions and cultural references.", - }), - }) - .openapi({ ref: "FineTuneBody" }); - -export const fineTuneResponseSchema = z - .object({ - data: z.string().openapi({ - description: "Response message from fine-tuning", - example: "Fine tuned model!", - }), - }) - .openapi({ ref: "FineTuneResponse" }); diff --git a/apps/api/src/routes/projects/index.ts b/apps/api/src/routes/projects/index.ts deleted file mode 100644 index 4cac06b2..00000000 --- a/apps/api/src/routes/projects/index.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Hono } from "@/lib/app"; -import { describeRoute } from "hono-openapi"; -import { resolver, validator as zValidator } from "hono-openapi/zod"; -import { - createProjectSchema, - projectResponseSchema, - updateProjectSchema, -} from "./schema"; - -const app = new Hono() - .post( - "/", - describeRoute({ - description: "Create a new project", - responses: { - 200: { - description: "Successfully created project", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - }, - }), - zValidator("json", createProjectSchema), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - const body = await c.req.valid("json"); - // TODO: Implement project creation logic - return c.json({ - data: { - id: "project_123", - name: body.name, - description: body.description, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }); - } catch (error) { - return c.json({ error: "Failed to create project" }, 500); - } - }, - ) - .get( - "/:id", - describeRoute({ - description: "Get project by ID", - responses: { - 200: { - description: "Successfully retrieved project", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - 404: { - description: "Project not found", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - const id = c.req.param("id"); - // TODO: Implement project retrieval logic - return c.json({ - data: { - id, - name: "Sample Project", - description: "A sample project description", - sourceLanguage: "en", - targetLanguages: ["es", "fr"], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }); - } catch (error) { - return c.json({ error: "Project not found" }, 404); - } - }, - ) - .patch( - "/:id", - describeRoute({ - description: "Update project", - responses: { - 200: { - description: "Successfully updated project", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - 404: { - description: "Project not found", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - }, - }), - zValidator("json", updateProjectSchema), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - const id = c.req.param("id"); - const body = await c.req.valid("json"); - // TODO: Implement project update logic - return c.json({ - data: { - id, - name: body.name, - description: body.description, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }); - } catch (error) { - return c.json({ error: "Project not found" }, 404); - } - }, - ) - .delete( - "/:id", - describeRoute({ - description: "Delete project", - responses: { - 200: { - description: "Successfully deleted project", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - 404: { - description: "Project not found", - content: { - "application/json": { - schema: resolver(projectResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - const id = c.req.param("id"); - // TODO: Implement project deletion logic - return c.json({ - data: null, - }); - } catch (error) { - return c.json({ error: "Project not found" }, 404); - } - }, - ); - -export default app; diff --git a/apps/api/src/routes/projects/schema.ts b/apps/api/src/routes/projects/schema.ts deleted file mode 100644 index 2789eab0..00000000 --- a/apps/api/src/routes/projects/schema.ts +++ /dev/null @@ -1,65 +0,0 @@ -import z from "zod"; -import "zod-openapi/extend"; - -export const createProjectSchema = z - .object({ - name: z.string().min(1).openapi({ - example: "My Translation Project", - description: "Name of the project", - }), - description: z.string().optional().openapi({ - example: "A project for translating my website content", - description: "Optional description of the project", - }), - }) - .openapi({ ref: "CreateProject" }); - -export const updateProjectSchema = z - .object({ - name: z.string().min(1).optional().openapi({ - example: "Updated Project Name", - description: "New name for the project", - }), - description: z.string().optional().openapi({ - example: "Updated project description", - description: "New description for the project", - }), - }) - .refine((data) => data.name || data.description, { - message: "At least one field must be provided", - }) - .openapi({ ref: "UpdateProject" }); - -export const projectResponseSchema = z - .object({ - data: z - .object({ - id: z.string().openapi({ - example: "project_123", - description: "Unique identifier for the project", - }), - name: z.string().openapi({ - example: "My Translation Project", - description: "Name of the project", - }), - description: z.string().nullable().openapi({ - example: "A project for translating my website content", - description: "Description of the project", - }), - createdAt: z.string().datetime().openapi({ - example: "2024-01-01T00:00:00Z", - description: "Timestamp when the project was created", - }), - updatedAt: z.string().datetime().openapi({ - example: "2024-01-01T00:00:00Z", - description: "Timestamp when the project was last updated", - }), - }) - .nullable() - .openapi({ description: "Project information" }), - error: z.string().optional().openapi({ - example: "Project not found", - description: "Error message if request failed", - }), - }) - .openapi({ ref: "ProjectResponse" }); diff --git a/apps/api/src/routes/teams/index.ts b/apps/api/src/routes/teams/index.ts deleted file mode 100644 index 3c52f8f4..00000000 --- a/apps/api/src/routes/teams/index.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { db } from "@/db"; -import { members, organizations, projects, users } from "@/db/schema"; -import { Hono } from "@/lib/app"; -import { eq } from "drizzle-orm"; -import { describeRoute } from "hono-openapi"; -import { resolver, validator as zValidator } from "hono-openapi/zod"; -import { createTeamSchema, inviteSchema, teamResponseSchema } from "./schema"; - -const app = new Hono() - .get( - "/", - describeRoute({ - description: "Get teams for current user", - responses: { - 200: { - description: "Successfully retrieved teams", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const user = c.get("user"); - - try { - const database = db(c.env.DB); - - const teams = await database - .select({ - id: organizations.id, - name: organizations.name, - slug: organizations.slug, - logo: organizations.logo, - role: members.role, - apiKey: organizations.apiKey, - plan: organizations.plan, - }) - .from(members) - .innerJoin( - organizations, - eq(organizations.id, members.organizationId), - ) - .innerJoin(users, eq(users.id, members.userId)) - .where(eq(users.id, user?.id)) - .all(); - - return c.json({ - data: teams.map((team) => ({ - id: team.id, - name: team.name, - slug: team.slug, - logo: team.logo, - role: team.role, - apiKey: team.apiKey, - plan: team.plan, - })), - }); - } catch (error) { - return c.json({ error: "Failed to retrieve teams" }, 500); - } - }, - ) - .post( - "/", - describeRoute({ - description: "Create a new team", - responses: { - 200: { - description: "Successfully created team", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - }, - }), - zValidator("json", createTeamSchema), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - const body = await c.req.valid("json"); - // TODO: Implement team creation logic - return c.json({ - data: { - id: "team_123", - name: body.name, - members: [ - { - email: "creator@example.com", - role: "owner", - }, - ], - }, - }); - } catch (error) { - return c.json({ error: "Failed to create team" }, 500); - } - }, - ) - .get( - "/:teamId", - describeRoute({ - description: "Get team details", - responses: { - 200: { - description: "Successfully retrieved team details", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - 404: { - description: "Team not found", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - const teamId = c.req.param("teamId"); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - // TODO: Implement team retrieval logic - return c.json({ - data: { - id: teamId, - name: "Example Team", - members: [ - { - email: "member1@example.com", - role: "owner", - }, - { - email: "member2@example.com", - role: "member", - }, - ], - }, - }); - } catch (error) { - return c.json({ error: "Team not found" }, 404); - } - }, - ) - .get( - "/:teamId/projects", - describeRoute({ - description: "Get projects for a team", - responses: { - 200: { - description: "Successfully retrieved team projects", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - 404: { - description: "Team not found", - content: { - "application/json": { - schema: resolver(teamResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const user = c.get("user"); - const teamId = c.req.param("teamId"); - - try { - const database = db(c.env.DB); - - // First check if user has access to this team - const teamMember = await database - .select({ - role: members.role, - }) - .from(members) - .where( - eq(members.userId, user?.id) && eq(members.organizationId, teamId), - ) - .get(); - - if (!teamMember) { - return c.json({ error: "You don't have access to this team" }, 403); - } - - const teamProjects = await database - .select({ - id: projects.id, - name: projects.name, - slug: projects.slug, - description: projects.description, - createdAt: projects.createdAt, - updatedAt: projects.updatedAt, - }) - .from(projects) - .where(eq(projects.organizationId, teamId)) - .all(); - - return c.json({ - data: teamProjects, - }); - } catch (error) { - return c.json({ error: "Failed to retrieve team projects" }, 500); - } - }, - ); - -export default app; diff --git a/apps/api/src/routes/teams/schema.ts b/apps/api/src/routes/teams/schema.ts deleted file mode 100644 index 75c1a837..00000000 --- a/apps/api/src/routes/teams/schema.ts +++ /dev/null @@ -1,58 +0,0 @@ -import z from "zod"; -import "zod-openapi/extend"; - -export const teamResponseSchema = z - .object({ - data: z - .object({ - id: z.string().openapi({ - example: "team_123", - description: "Unique identifier for the team", - }), - name: z.string().openapi({ - example: "My Team", - description: "Name of the team", - }), - members: z - .array( - z.object({ - email: z.string().email().openapi({ - example: "user@example.com", - description: "Email address of team member", - }), - role: z.enum(["owner", "member", "pending"]).openapi({ - example: "member", - description: "Role of the team member", - }), - }), - ) - .openapi({ - description: "List of team members", - }), - }) - .nullable() - .openapi({ description: "Team information" }), - error: z.string().optional().openapi({ - example: "Team not found", - description: "Error message if request failed", - }), - }) - .openapi({ ref: "TeamResponse" }); - -export const createTeamSchema = z - .object({ - name: z.string().min(1).openapi({ - example: "My Team", - description: "Name for the new team", - }), - }) - .openapi({ ref: "CreateTeam" }); - -export const inviteSchema = z - .object({ - email: z.string().email().openapi({ - example: "user@example.com", - description: "Email address of user to invite", - }), - }) - .openapi({ ref: "InviteUser" }); diff --git a/apps/api/src/routes/telemetry/index.ts b/apps/api/src/routes/telemetry/index.ts deleted file mode 100644 index c17aeaea..00000000 --- a/apps/api/src/routes/telemetry/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Hono } from "@/lib/app"; -import { describeRoute } from "hono-openapi"; -import { resolver, validator as zValidator } from "hono-openapi/zod"; -import { querySchema } from "./schema"; -import { responseSchema } from "./schema"; - -const app = new Hono().get( - "/", - describeRoute({ - description: "Say hello to the user", - responses: { - 200: { - description: "Successful greeting response", - content: { - "text/plain": { - schema: resolver(responseSchema), - }, - }, - }, - }, - }), - zValidator("query", querySchema), - (c) => { - const query = c.req.valid("query"); - return c.json({ data: `Hello ${query?.name ?? "Hono"}!` }); - }, -); - -export default app; diff --git a/apps/api/src/routes/telemetry/schema.ts b/apps/api/src/routes/telemetry/schema.ts deleted file mode 100644 index 122f9be6..00000000 --- a/apps/api/src/routes/telemetry/schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import z from "zod"; -import "zod-openapi/extend"; - -export const querySchema = z - .object({ - name: z.string().optional().openapi({ example: "Steven" }), - }) - .openapi({ ref: "Query" }); - -export const responseSchema = z.string().openapi({ example: "Hello Steven!" }); diff --git a/apps/api/src/routes/translate/index.ts b/apps/api/src/routes/translate/index.ts deleted file mode 100644 index b288a758..00000000 --- a/apps/api/src/routes/translate/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Hono } from "@/lib/app"; -import { describeRoute } from "hono-openapi"; -import { resolver, validator as zValidator } from "hono-openapi/zod"; -import { - batchTextSchema, - chatSchema, - objectSchema, - recognizeSchema, - textSchema, -} from "./schema"; - -const app = new Hono(); - -app - .post( - "/text", - describeRoute({ - description: "Translate text to target language", - responses: { - 200: { - description: "Successfully translated text", - content: { - "application/json": { - schema: resolver(textSchema), - }, - }, - }, - }, - }), - zValidator("json", textSchema), - async (c) => { - const { text, sourceLocale, targetLocale } = await c.req.json(); - return c.json({ data: text }); // Placeholder response - }, - ) - .post( - "/text/batch", - describeRoute({ - description: "Translate text to multiple target languages", - responses: { - 200: { - description: "Successfully translated text to multiple languages", - content: { - "application/json": { - schema: resolver(batchTextSchema), - }, - }, - }, - }, - }), - zValidator("json", batchTextSchema), - async (c) => { - const { text, sourceLocale, targetLocales, fast } = await c.req.json(); - return c.json({ data: [text] }); // Placeholder response - }, - ) - .post( - "/object", - describeRoute({ - description: "Translate object values to target language", - responses: { - 200: { - description: "Successfully translated object", - content: { - "application/json": { - schema: resolver(objectSchema), - }, - }, - }, - }, - }), - zValidator("json", objectSchema), - async (c) => { - const { content, sourceLocale, targetLocale } = await c.req.json(); - return c.json({ data: content }); // Placeholder response - }, - ) - .post( - "/chat", - describeRoute({ - description: "Translate chat messages to target language", - responses: { - 200: { - description: "Successfully translated chat messages", - content: { - "application/json": { - schema: resolver(chatSchema), - }, - }, - }, - }, - }), - zValidator("json", chatSchema), - async (c) => { - const { messages, sourceLocale, targetLocale } = await c.req.json(); - return c.json({ data: messages }); // Placeholder response - }, - ) - .post( - "/recognize", - describeRoute({ - description: "Recognize the locale of provided text", - responses: { - 200: { - description: "Successfully recognized locale", - content: { - "application/json": { - schema: resolver(recognizeSchema), - }, - }, - }, - }, - }), - zValidator("json", recognizeSchema), - async (c) => { - const { text } = await c.req.json(); - return c.json({ data: "en" }); // Placeholder response - }, - ); - -export default app; diff --git a/apps/api/src/routes/translate/schema.ts b/apps/api/src/routes/translate/schema.ts deleted file mode 100644 index f3e683b7..00000000 --- a/apps/api/src/routes/translate/schema.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from "zod"; - -export const translationBaseSchema = z.object({ - sourceLocale: z.string(), - targetLocale: z.string(), -}); - -export const batchTranslationSchema = z.object({ - sourceLocale: z.string(), - targetLocales: z.array(z.string()), - fast: z.boolean().optional(), -}); - -export const textSchema = z.object({ - text: z.string(), - ...translationBaseSchema.shape, -}); - -export const batchTextSchema = z.object({ - text: z.string(), - ...batchTranslationSchema.shape, -}); - -export const objectSchema = z.object({ - content: z.record(z.string()), - ...translationBaseSchema.shape, -}); - -export const chatMessageSchema = z.object({ - name: z.string(), - text: z.string(), -}); - -export const chatSchema = z.object({ - messages: z.array(chatMessageSchema), - ...translationBaseSchema.shape, -}); - -export const recognizeSchema = z.object({ - text: z.string(), -}); diff --git a/apps/api/src/routes/users/index.ts b/apps/api/src/routes/users/index.ts deleted file mode 100644 index b016e97d..00000000 --- a/apps/api/src/routes/users/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { Hono } from "@/lib/app"; -import { describeRoute } from "hono-openapi"; -import { resolver, validator as zValidator } from "hono-openapi/zod"; -import { updateUserBodySchema, userResponseSchema } from "./schema"; - -const app = new Hono(); - -app - .get( - "/me", - describeRoute({ - description: "Get current user profile", - responses: { - 200: { - description: "Successfully retrieved user profile", - content: { - "application/json": { - schema: resolver(userResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(userResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const session = c.get("session"); - const user = c.get("user"); - - if (!user) return c.body(null, 401); - - return c.json({ - data: { - id: user.id, - email: user.email, - name: user.name, - activeOrganizationId: session.activeOrganizationId, - }, - }); - }, - ) - .patch( - "/me", - describeRoute({ - description: "Update current user profile", - responses: { - 200: { - description: "Successfully updated user profile", - content: { - "application/json": { - schema: resolver(userResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(userResponseSchema), - }, - }, - }, - }, - }), - zValidator("json", updateUserBodySchema), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - const body = await c.req.valid("json"); - // TODO: Implement user profile update logic - return c.json({ - data: { - email: body.email, - name: body.name, - provider: "github", - }, - }); - } catch (error) { - return c.json({ error: "Failed to update user profile" }, 401); - } - }, - ) - .delete( - "/me", - describeRoute({ - description: "Delete current user account", - responses: { - 200: { - description: "Successfully deleted user account", - content: { - "application/json": { - schema: resolver(userResponseSchema), - }, - }, - }, - 401: { - description: "Unauthorized - Invalid or missing token", - content: { - "application/json": { - schema: resolver(userResponseSchema), - }, - }, - }, - }, - }), - async (c) => { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return c.json({ error: "No token provided" }, 401); - } - - try { - // TODO: Implement user account deletion logic - return c.json({ - data: { - email: "", - name: "", - provider: "github", - }, - }); - } catch (error) { - return c.json({ error: "Failed to delete user account" }, 401); - } - }, - ); - -export default app; diff --git a/apps/api/src/routes/users/schema.ts b/apps/api/src/routes/users/schema.ts deleted file mode 100644 index b9753991..00000000 --- a/apps/api/src/routes/users/schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import z from "zod"; -import "zod-openapi/extend"; - -export const userResponseSchema = z - .object({ - data: z - .object({ - email: z.string().email().openapi({ - example: "user@example.com", - description: "User's email address", - }), - name: z.string().openapi({ - example: "John Doe", - description: "User's full name", - }), - provider: z.enum(["github", "google"]).openapi({ - example: "github", - description: "OAuth provider used for authentication", - }), - }) - .nullable() - .openapi({ description: "User profile information" }), - error: z.string().optional().openapi({ - example: "Failed to get user profile", - description: "Error message if request failed", - }), - }) - .openapi({ ref: "UserResponse" }); - -export const updateUserBodySchema = z - .object({ - name: z.string().min(1).optional().openapi({ - example: "John Doe", - description: "New name to update user profile with", - }), - email: z.string().email().optional().openapi({ - example: "user@example.com", - description: "New email to update user profile with", - }), - }) - .refine((data) => data.name || data.email, { - message: "At least one field must be provided", - }) - .openapi({ ref: "UpdateUserBody" }); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 3e119e1b..00000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "jsx": "react-jsx", - "jsxImportSource": "react", - "lib": [ - "ESNext" - ], - "types": [ - "@cloudflare/workers-types/2023-07-01", - "bun-types" - ], - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } - }, -} \ No newline at end of file diff --git a/apps/api/wrangler.json b/apps/api/wrangler.json deleted file mode 100644 index aeaf77ab..00000000 --- a/apps/api/wrangler.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "languine-api", - "main": "src/index.ts", - "compatibility_date": "2024-12-29", - "workers_dev": true, - "compatibility_flags": ["nodejs_compat"], - "placement": { - "mode": "smart" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "languine-development", - "database_id": "development", - "migrations_dir": "./drizzle" - } - ], - "env": { - "production": { - "name": "languine-api", - "route": { - "pattern": "api.languine.ai/*", - "zone_name": "languine.ai" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "languine-production", - "database_id": "5f6dc91e-405e-423a-af2f-eca985fb86ec", - "migrations_dir": "./drizzle" - } - ] - }, - "staging": { - "name": "languine-api-staging", - "route": { - "pattern": "api-staging.languine.ai/*", - "zone_name": "languine.ai" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "languine-staging", - "database_id": "", - "migrations_dir": "./drizzle" - } - ] - } - } -} \ No newline at end of file diff --git a/apps/web/.env-example b/apps/web/.env-example index 8d783622..cf7da712 100644 --- a/apps/web/.env-example +++ b/apps/web/.env-example @@ -1,3 +1,6 @@ NEXT_PUBLIC_API_ENDPOINT=http://localhost:3002 OPEN_PANEL_CLIENT_SECRET= NEXT_PUBLIC_OPEN_PANEL_CLIENT_ID= +TURSO_DATABASE_URL= +TURSO_AUTH_TOKEN= +RESEND_API_KEY= \ No newline at end of file diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 00000000..95edb28f --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,13 @@ +require("dotenv").config(); + +import type { Config } from "drizzle-kit"; + +export default { + schema: "./src/db/schema.ts", + out: "./migrations", + dialect: "turso", + dbCredentials: { + url: process.env.TURSO_DATABASE_URL!, + authToken: process.env.TURSO_AUTH_TOKEN, + }, +} satisfies Config; diff --git a/apps/api/drizzle/0000_busy_mandrill.sql b/apps/web/drizzle/0000_busy_mandrill.sql similarity index 100% rename from apps/api/drizzle/0000_busy_mandrill.sql rename to apps/web/drizzle/0000_busy_mandrill.sql diff --git a/apps/api/drizzle/0001_steep_scrambler.sql b/apps/web/drizzle/0001_steep_scrambler.sql similarity index 100% rename from apps/api/drizzle/0001_steep_scrambler.sql rename to apps/web/drizzle/0001_steep_scrambler.sql diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/web/drizzle/meta/0000_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0000_snapshot.json rename to apps/web/drizzle/meta/0000_snapshot.json diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/web/drizzle/meta/0001_snapshot.json similarity index 100% rename from apps/api/drizzle/meta/0001_snapshot.json rename to apps/web/drizzle/meta/0001_snapshot.json diff --git a/apps/api/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json similarity index 100% rename from apps/api/drizzle/meta/_journal.json rename to apps/web/drizzle/meta/_journal.json diff --git a/apps/web/migrations/0000_sad_red_shift.sql b/apps/web/migrations/0000_sad_red_shift.sql new file mode 100644 index 00000000..2ebca147 --- /dev/null +++ b/apps/web/migrations/0000_sad_red_shift.sql @@ -0,0 +1,129 @@ +CREATE TABLE `accounts` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `accounts_user_id_idx` ON `accounts` (`user_id`);--> statement-breakpoint +CREATE INDEX `provider_compound_idx` ON `accounts` (`provider_id`,`account_id`);--> statement-breakpoint +CREATE TABLE `invitations` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `email` text NOT NULL, + `role` text, + `status` text NOT NULL, + `expires_at` integer NOT NULL, + `inviter_id` text NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`inviter_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `org_email_idx` ON `invitations` (`organization_id`,`email`);--> statement-breakpoint +CREATE INDEX `invitations_expires_at_idx` ON `invitations` (`expires_at`);--> statement-breakpoint +CREATE TABLE `members` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `user_id` text NOT NULL, + `role` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `org_user_idx` ON `members` (`organization_id`,`user_id`);--> statement-breakpoint +CREATE TABLE `organizations` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text, + `logo` text, + `plan` text DEFAULT 'free' NOT NULL, + `api_key` text NOT NULL, + `created_at` integer NOT NULL, + `metadata` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `organizations_slug_unique` ON `organizations` (`slug`);--> statement-breakpoint +CREATE UNIQUE INDEX `organizations_api_key_unique` ON `organizations` (`api_key`);--> statement-breakpoint +CREATE INDEX `slug_idx` ON `organizations` (`slug`);--> statement-breakpoint +CREATE INDEX `org_api_key_idx` ON `organizations` (`api_key`);--> statement-breakpoint +CREATE TABLE `project_settings` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `cache` integer DEFAULT true NOT NULL, + `context` integer DEFAULT true NOT NULL, + `temperature` real DEFAULT 0 NOT NULL, + `instructions` text, + `memory` integer DEFAULT true NOT NULL, + `grammar` integer DEFAULT true NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `project_idx` ON `project_settings` (`project_id`);--> statement-breakpoint +CREATE TABLE `projects` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `description` text, + `organization_id` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `org_idx` ON `projects` (`organization_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `slug_org_idx` ON `projects` (`slug`,`organization_id`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + `active_organization_id` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint +CREATE INDEX `user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint +CREATE INDEX `token_idx` ON `sessions` (`token`);--> statement-breakpoint +CREATE INDEX `expires_at_idx` ON `sessions` (`expires_at`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer NOT NULL, + `image` text, + `api_key` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_api_key_unique` ON `users` (`api_key`);--> statement-breakpoint +CREATE INDEX `email_idx` ON `users` (`email`);--> statement-breakpoint +CREATE TABLE `verifications` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE INDEX `identifier_idx` ON `verifications` (`identifier`);--> statement-breakpoint +CREATE INDEX `verifications_expires_at_idx` ON `verifications` (`expires_at`); \ No newline at end of file diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..62b55634 --- /dev/null +++ b/apps/web/migrations/meta/0000_snapshot.json @@ -0,0 +1,895 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9d8306a6-bd63-4a9a-95ff-88847f376235", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "provider_compound_idx": { + "name": "provider_compound_idx", + "columns": [ + "provider_id", + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitations": { + "name": "invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "org_email_idx": { + "name": "org_email_idx", + "columns": [ + "organization_id", + "email" + ], + "isUnique": false + }, + "invitations_expires_at_idx": { + "name": "invitations_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "members": { + "name": "members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "org_user_idx": { + "name": "org_user_idx", + "columns": [ + "organization_id", + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_api_key_unique": { + "name": "organizations_api_key_unique", + "columns": [ + "api_key" + ], + "isUnique": true + }, + "slug_idx": { + "name": "slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "org_api_key_idx": { + "name": "org_api_key_idx", + "columns": [ + "api_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_settings": { + "name": "project_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache": { + "name": "cache", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "context": { + "name": "context", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory": { + "name": "memory", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "grammar": { + "name": "grammar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_idx": { + "name": "project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "org_idx": { + "name": "org_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "slug_org_idx": { + "name": "slug_org_idx", + "columns": [ + "slug", + "organization_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "token_idx": { + "name": "token_idx", + "columns": [ + "token" + ], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_api_key_unique": { + "name": "users_api_key_unique", + "columns": [ + "api_key" + ], + "isUnique": true + }, + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + }, + "verifications_expires_at_idx": { + "name": "verifications_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json new file mode 100644 index 00000000..783e6737 --- /dev/null +++ b/apps/web/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1736332317965, + "tag": "0000_sad_red_shift", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index f006dc3c..f7c667ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,10 +7,15 @@ "build": "next build", "start": "next start", "lint": "next lint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "email:dev": "email dev --port 3002 --dir src/emails/templates", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" }, "dependencies": { "@hookform/resolvers": "^3.10.0", + "@libsql/client": "^0.14.0", "@openpanel/nextjs": "^1.0.7", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -25,13 +30,22 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@tanstack/react-query": "^5.62.16", + "@trpc/client": "^11.0.0-rc.688", + "@react-email/components": "0.0.32", + "@react-email/font": "^0.0.9", + "@trpc/react-query": "^11.0.0-rc.688", + "@trpc/server": "^11.0.0-rc.688", "better-auth": "^1.1.10", "class-variance-authority": "^0.7.1", + "client-only": "^0.0.1", "clsx": "^2.1.1", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.38.3", "input-otp": "^1.4.2", "lucide-react": "^0.469.0", "motion": "^11.16.0", - "next": "15.1.3", + "next": "15.1.4", "next-international": "^1.3.1", "next-safe-action": "^7.10.2", "next-themes": "^0.4.4", @@ -41,13 +55,17 @@ "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", "recharts": "^2.15.0", + "server-only": "^0.0.1", "sonner": "^1.7.1", + "superjson": "^2.2.2", "tailwind-merge": "^2.6.0", "zod": "^3.24.1" }, "devDependencies": { + "@tanstack/react-query-devtools": "^5.62.16", "@types/node": "^22", "@types/react": "^19", + "react-email": "3.0.5", "@types/react-dom": "^19", "languine": "^1.0.2", "postcss": "^8", diff --git a/apps/web/src/app/[locale]/(dashboard)/login/page.tsx b/apps/web/src/app/[locale]/(dashboard)/login/page.tsx index e1582550..34980f01 100644 --- a/apps/web/src/app/[locale]/(dashboard)/login/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/login/page.tsx @@ -3,11 +3,9 @@ import Login from "@/components/login"; import { Logo } from "@/components/logo"; import MatrixTextWall from "@/components/matrix"; import { StackedCode } from "@/components/stacked-code"; -import { getOrganization } from "@/lib/queries"; import { getI18n } from "@/locales/server"; import type { Metadata } from "next"; import Link from "next/link"; -import { redirect } from "next/navigation"; export async function generateMetadata(): Promise { const t = await getI18n(); @@ -19,14 +17,8 @@ export async function generateMetadata(): Promise { } export default async function Page() { - const org = await getOrganization(); - const project = "default"; const t = await getI18n(); - if (org.data) { - redirect(`/${org.data.slug}/${project}`); - } - return (
diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx index 90defc5e..fddde6cb 100644 --- a/apps/web/src/app/[locale]/layout.tsx +++ b/apps/web/src/app/[locale]/layout.tsx @@ -1,10 +1,11 @@ +import "../globals.css"; + +import { I18nProviderClient } from "@/locales/client"; +import { TRPCProvider } from "@/trpc/client"; import { OpenPanelComponent } from "@openpanel/nextjs"; import type { Metadata } from "next"; import { Geist_Mono } from "next/font/google"; -import "../globals.css"; -import { I18nProviderClient } from "@/locales/client"; - const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], @@ -34,7 +35,9 @@ export default async function RootLayout({ disabled={process.env.NODE_ENV !== "production"} /> - {children} + + {children} + ); diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..78eb754b --- /dev/null +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth/server"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/apps/web/src/app/api/trpc/[trpc] /route.ts b/apps/web/src/app/api/trpc/[trpc] /route.ts new file mode 100644 index 00000000..5cb0560f --- /dev/null +++ b/apps/web/src/app/api/trpc/[trpc] /route.ts @@ -0,0 +1,13 @@ +import { createTRPCContext } from "@/trpc/init"; +import { appRouter } from "@/trpc/routers/_app"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: createTRPCContext, + }); + +export { handler as GET, handler as POST }; diff --git a/apps/web/src/components/dashboard/header.tsx b/apps/web/src/components/dashboard/header.tsx index c50eec0b..40e2d859 100644 --- a/apps/web/src/components/dashboard/header.tsx +++ b/apps/web/src/components/dashboard/header.tsx @@ -2,17 +2,13 @@ import { ChangeLanguage } from "@/components/change-language"; import { TeamSelectorServer } from "@/components/team-selector.server"; import { UserMenu } from "@/components/user-menu"; import Link from "next/link"; -import { Suspense } from "react"; import { MdOutlineBook } from "react-icons/md"; -import { Skeleton } from "../ui/skeleton"; export function Header() { return (
- }> - - +
diff --git a/apps/web/src/components/github-sign-in.tsx b/apps/web/src/components/github-sign-in.tsx index b12ea90d..0b46e6c8 100644 --- a/apps/web/src/components/github-sign-in.tsx +++ b/apps/web/src/components/github-sign-in.tsx @@ -2,7 +2,7 @@ import { OutlinedButton } from "@/components/ui/outlined-button"; import { Spinner } from "@/components/ui/spinner"; -import { auth } from "@/lib/auth"; +import { authClient } from "@/lib/auth/client"; import { useI18n } from "@/locales/client"; import { useState } from "react"; import { FaGithub } from "react-icons/fa"; @@ -14,7 +14,7 @@ export default function GithubSignIn() { const handleGithubLogin = async () => { setIsLoading(true); try { - await auth.signIn.social({ + await authClient.signIn.social({ provider: "github", callbackURL: `${window.location.origin}/login`, }); diff --git a/apps/web/src/components/google-sign-in.tsx b/apps/web/src/components/google-sign-in.tsx index 5099c743..2caee579 100644 --- a/apps/web/src/components/google-sign-in.tsx +++ b/apps/web/src/components/google-sign-in.tsx @@ -2,7 +2,7 @@ import { OutlinedButton } from "@/components/ui/outlined-button"; import { Spinner } from "@/components/ui/spinner"; -import { auth } from "@/lib/auth"; +import { authClient } from "@/lib/auth/client"; import { useI18n } from "@/locales/client"; import { useState } from "react"; import { FaGoogle } from "react-icons/fa"; @@ -16,7 +16,7 @@ export default function GoogleSignIn() { setIsLoading(true); try { - await auth.signIn.social({ + await authClient.signIn.social({ provider: "google", callbackURL: `${window.location.origin}/login`, }); diff --git a/apps/web/src/components/login.tsx b/apps/web/src/components/login.tsx index 039ef4c5..6d7986b1 100644 --- a/apps/web/src/components/login.tsx +++ b/apps/web/src/components/login.tsx @@ -12,10 +12,10 @@ export default async function Login() {

{t("login.description")}

- +
- +
@@ -35,4 +35,4 @@ export default async function Login() {
); -} \ No newline at end of file +} diff --git a/apps/web/src/components/sign-in.tsx b/apps/web/src/components/sign-in.tsx index c982f015..9f93697c 100644 --- a/apps/web/src/components/sign-in.tsx +++ b/apps/web/src/components/sign-in.tsx @@ -1,12 +1,12 @@ "use client"; -import { auth } from "@/lib/auth"; +import { authClient } from "@/lib/auth/client"; import { useI18n } from "@/locales/client"; import Link from "next/link"; export function SignIn() { const t = useI18n(); - const { data: session } = auth.useSession(); + const { data: session } = authClient.useSession(); return ( diff --git a/apps/web/src/components/team-selector.server.tsx b/apps/web/src/components/team-selector.server.tsx index e07e527a..5f23bdd0 100644 --- a/apps/web/src/components/team-selector.server.tsx +++ b/apps/web/src/components/team-selector.server.tsx @@ -1,8 +1,16 @@ -import { getTeams } from "@/lib/queries"; +import { Skeleton } from "@/components/ui/skeleton"; +import { HydrateClient, trpc } from "@/trpc/server"; +import { Suspense } from "react"; import { TeamSelector } from "./team-selector"; export async function TeamSelectorServer() { - const teams = await getTeams(); + trpc.organization.getAll.prefetch(); - return ; + return ( + + }> + + + + ); } diff --git a/apps/web/src/components/team-selector.tsx b/apps/web/src/components/team-selector.tsx index 20ae89df..31a54f96 100644 --- a/apps/web/src/components/team-selector.tsx +++ b/apps/web/src/components/team-selector.tsx @@ -14,26 +14,28 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import type { GetTeamsResponse } from "@/lib/queries"; import { useI18n } from "@/locales/client"; +import { trpc } from "@/trpc/client"; import { Check, Plus, Settings } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import * as React from "react"; -export function TeamSelector({ teams }: { teams: GetTeamsResponse }) { +export function TeamSelector() { const t = useI18n(); const params = useParams(); const router = useRouter(); + const [organizations] = trpc.organization.getAll.useSuspenseQuery(); + const teamId = params.team; const projectSlug = params.project; - const currentTeam = teams - ? teams.find((team) => team?.id === teamId) || teams.at(0) + const currentTeam = organizations + ? organizations.find((org) => org?.id === teamId) || organizations.at(0) : null; - const currentProject = currentTeam?.projects.find( - (project) => project.slug === projectSlug, + const currentProject = currentTeam?.projects?.find( + (project: { slug: string }) => project.slug === projectSlug, ); const [open, setOpen] = React.useState(false); @@ -61,16 +63,16 @@ export function TeamSelector({ teams }: { teams: GetTeamsResponse }) { {t("teamSelector.teams")}
- {teams.map((team) => ( + {organizations.map((org) => (
{ - router.push(`/${team?.id}/${projectSlug}`); + router.push(`/${org?.id}/${projectSlug}`); setOpen(false); }} > - {team?.name} + {org?.name}
- {currentTeam?.id === team?.id && ( + {currentTeam?.id === org?.id && ( )}
diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index b881e768..f7cf499f 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -8,19 +8,19 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { auth } from "@/lib/auth"; +import { authClient } from "@/lib/auth/client"; import { useI18n } from "@/locales/client"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; export function UserMenu() { - const { data: session } = auth.useSession(); + const { data: session } = authClient.useSession(); const params = useParams(); const t = useI18n(); const router = useRouter(); const handleSignOut = async () => { - await auth.signOut({ + await authClient.signOut({ fetchOptions: { onSuccess: () => { router.push("/"); diff --git a/apps/web/src/db/index.ts b/apps/web/src/db/index.ts new file mode 100644 index 00000000..a0af0d47 --- /dev/null +++ b/apps/web/src/db/index.ts @@ -0,0 +1,9 @@ +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; + +const turso = createClient({ + url: process.env.TURSO_DATABASE_URL!, + authToken: process.env.TURSO_AUTH_TOKEN, +}); + +export const db = drizzle(turso); diff --git a/apps/api/README.md b/apps/web/src/db/queries/delete.ts similarity index 100% rename from apps/api/README.md rename to apps/web/src/db/queries/delete.ts diff --git a/apps/web/src/db/queries/insert.ts b/apps/web/src/db/queries/insert.ts new file mode 100644 index 00000000..9f2534ec --- /dev/null +++ b/apps/web/src/db/queries/insert.ts @@ -0,0 +1,42 @@ +import { db } from "@/db"; +import { members, organizations, projects, sessions } from "@/db/schema"; +import { createId } from "@paralleldrive/cuid2"; +import { eq } from "drizzle-orm"; +import slugify from "slugify"; + +export async function createDefaultOrganization(user: { + id: string; + name: string; +}) { + // Create default organization for new user + const org = await db + .insert(organizations) + .values({ + name: user.name, + slug: `${slugify(user.name, { lower: true })}-${createId().slice(0, 8)}`, + }) + .returning() + .get(); + + // Add user as member of organization + await db.insert(members).values({ + userId: user.id, + organizationId: org.id, + role: "owner", + }); + + // Create default project for new organization + await db.insert(projects).values({ + name: "Default", + organizationId: org.id, + slug: "default", + }); + + // Set active organization for new user's session + await db + .update(sessions) + .set({ activeOrganizationId: org.id }) + .where(eq(sessions.userId, user.id)); + + return org; +} diff --git a/apps/web/src/db/queries/select.ts b/apps/web/src/db/queries/select.ts new file mode 100644 index 00000000..ec47de11 --- /dev/null +++ b/apps/web/src/db/queries/select.ts @@ -0,0 +1,34 @@ +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import { members, organizations, projects } from "../schema"; + +export const getDefaultOrganization = async (userId: string) => { + return await db + .select() + .from(members) + .where(eq(members.userId, userId)) + .leftJoin(organizations, eq(organizations.id, members.organizationId)) + .limit(1) + .get(); +}; + +export const getAllOrganizationsWithProjects = async () => { + const orgs = await db.select().from(organizations).all(); + + const orgsWithProjects = await Promise.all( + orgs.map(async (org) => { + const orgProjects = await db + .select() + .from(projects) + .where(eq(projects.organizationId, org.id)) + .all(); + + return { + ...org, + projects: orgProjects, + }; + }), + ); + + return orgsWithProjects; +}; diff --git a/apps/web/src/db/queries/update.ts b/apps/web/src/db/queries/update.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/src/db/schema.ts b/apps/web/src/db/schema.ts similarity index 100% rename from apps/api/src/db/schema.ts rename to apps/web/src/db/schema.ts diff --git a/apps/api/src/emails/components/footer.tsx b/apps/web/src/emails/components/footer.tsx similarity index 81% rename from apps/api/src/emails/components/footer.tsx rename to apps/web/src/emails/components/footer.tsx index a296c67d..b692ac92 100644 --- a/apps/api/src/emails/components/footer.tsx +++ b/apps/web/src/emails/components/footer.tsx @@ -1,11 +1,13 @@ -import { getAppUrl } from "@/lib/envs"; +import { getAppUrl } from "@/lib/url"; import { Column, Img, Link, Row, Section, Text } from "@react-email/components"; +const appUrl = getAppUrl(); + export function Footer() { return (
Separator - X + X @@ -36,7 +33,7 @@ export function Footer() { className="text-black no-underline text-xl" > GitHub Languine Logo(process.env.NEXT_PUBLIC_API_ENDPOINT!, { - fetch: async (input: RequestInfo | URL, init?: RequestInit) => { - const requestHeaders = await getCookieHeaders(); - - return fetch(input, { - ...init, - credentials: "include", - headers: { - ...init?.headers, - ...requestHeaders, - }, - }); - }, -}); diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts deleted file mode 100644 index 76bf3b5f..00000000 --- a/apps/web/src/lib/auth.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { organizationClient } from "better-auth/client/plugins"; -import { nextCookies } from "better-auth/next-js"; -import { createAuthClient } from "better-auth/react"; - -export const auth = createAuthClient({ - baseURL: process.env.NEXT_PUBLIC_API_ENDPOINT, - basePath: "/auth", - socialProviders: { - github: { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - }, - }, - session: { - cookieCache: { - enabled: true, - maxAge: 5 * 60, - }, - }, - advanced: { - crossSubDomainCookies: { - enabled: true, - }, - }, - plugins: [nextCookies(), organizationClient()], -}); diff --git a/apps/web/src/lib/auth/client.ts b/apps/web/src/lib/auth/client.ts new file mode 100644 index 00000000..89e659d9 --- /dev/null +++ b/apps/web/src/lib/auth/client.ts @@ -0,0 +1,14 @@ +import { organizationClient } from "better-auth/client/plugins"; +import { nextCookies } from "better-auth/next-js"; +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + baseURL: process.env.BETTER_AUTH_BASE_URL, + session: { + cookieCache: { + enabled: true, + maxAge: 5 * 60, + }, + }, + plugins: [nextCookies(), organizationClient()], +}); diff --git a/apps/web/src/lib/auth/middleware.ts b/apps/web/src/lib/auth/middleware.ts new file mode 100644 index 00000000..93d0cf01 --- /dev/null +++ b/apps/web/src/lib/auth/middleware.ts @@ -0,0 +1,24 @@ +import type { Session, User } from "better-auth"; +import { headers } from "next/headers"; +import { cache } from "react"; + +type SessionData = { + user: User; + session: Session; +}; + +export const getSessionFromRequest = cache( + async (): Promise => { + return await fetch( + `${process.env.BETTER_AUTH_BASE_URL}/api/auth/get-session`, + { + headers: { + cookie: (await headers()).get("cookie") || "", + }, + }, + ).then(async (res) => { + if (!res.ok) return null; + return res.json(); + }); + }, +); diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts new file mode 100644 index 00000000..bd8ab57f --- /dev/null +++ b/apps/web/src/lib/auth/server.ts @@ -0,0 +1,71 @@ +import { db } from "@/db"; +import { createDefaultOrganization } from "@/db/queries/insert"; +import { getDefaultOrganization } from "@/db/queries/select"; +import * as schema from "@/db/schema"; +import WelcomeEmail from "@/emails/templates/welcome"; +import { resend } from "@/lib/resend"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { organization } from "better-auth/plugins"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "sqlite", + usePlural: true, + schema, + }), + secret: process.env.BETTER_AUTH_SECRET, + baseURL: process.env.BETTER_AUTH_BASE_URL, + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + }, + google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }, + }, + databaseHooks: { + user: { + create: { + after: async (user) => { + const org = await createDefaultOrganization(user); + + // Send welcome email to new user + try { + await resend.emails.send({ + from: "Languine ", + to: user.email, + subject: "Welcome to Languine", + react: WelcomeEmail({ name: user.name }), + }); + } catch (error) { + console.error("Error sending welcome email", error); + } + }, + }, + }, + session: { + create: { + before: async (session) => { + const org = await getDefaultOrganization(session.userId); + + return { + data: { + ...session, + activeOrganizationId: org?.organizations?.id, + }, + }; + }, + }, + }, + }, + session: { + cookieCache: { + enabled: true, + maxAge: 5 * 60, + }, + }, + plugins: [organization()], +}); diff --git a/apps/web/src/lib/cookies.ts b/apps/web/src/lib/cookies.ts deleted file mode 100644 index 02b2c32b..00000000 --- a/apps/web/src/lib/cookies.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { headers } from "next/headers"; - -export const getCookieHeaders = async () => { - const headersList = await headers(); - const cookie = headersList.get("cookie"); - - const requestHeaders: HeadersInit = {}; - if (cookie) { - requestHeaders.cookie = cookie; - } - - return requestHeaders; -}; diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts deleted file mode 100644 index 73cfec9f..00000000 --- a/apps/web/src/lib/queries.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { api } from "./api"; -import { auth } from "./auth"; -import { getCookieHeaders } from "./cookies"; - -export const getOrganization = async () => { - const requestHeaders = await getCookieHeaders(); - - return auth.organization.getFullOrganization({ - fetchOptions: { - headers: requestHeaders, - credentials: "include", - }, - }); -}; - -export type GetTeamsResponse = Awaited>; - -export const getTeams = async () => { - const response = await api.teams.$get(); - - if (!response.ok) { - return []; - } - - const teams = await response.json(); - - const teamsAndProjects = await Promise.all( - teams?.data.map(async (team) => { - const projects = await api.teams[":teamId"].projects.$get({ - param: { - teamId: team.id, - }, - }); - - if (!projects.ok) { - return null; - } - - const projectsResponse = await projects.json(); - - return { - ...team, - projects: projectsResponse.data, - }; - }), - ); - - return teamsAndProjects; -}; diff --git a/apps/web/src/lib/resend.ts b/apps/web/src/lib/resend.ts new file mode 100644 index 00000000..6ed938e8 --- /dev/null +++ b/apps/web/src/lib/resend.ts @@ -0,0 +1,3 @@ +import { Resend } from "resend"; + +export const resend = new Resend(process.env.RESEND_API_KEY); diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts index ac591d1c..0cd81127 100644 --- a/apps/web/src/lib/session.ts +++ b/apps/web/src/lib/session.ts @@ -1,12 +1,13 @@ +import { authClient } from "@/lib/auth/client"; import { headers } from "next/headers"; -import { auth } from "./auth"; +import { cache } from "react"; -export const getSession = async () => { - const session = await auth.getSession({ +export const getSession = cache(async () => { + const session = await authClient.getSession({ fetchOptions: { headers: await headers(), }, }); return session; -}; +}); diff --git a/apps/web/src/lib/url.ts b/apps/web/src/lib/url.ts new file mode 100644 index 00000000..79019cbf --- /dev/null +++ b/apps/web/src/lib/url.ts @@ -0,0 +1,7 @@ +export function getAppUrl() { + if (!process.env.VERCEL_URL) { + return "http://localhost:3000"; + } + + return process.env.VERCEL_URL; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 8f47c63d..5b0797d0 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,6 +1,8 @@ import { createI18nMiddleware } from "next-international/middleware"; -import type { NextRequest } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import languineConfig from "../languine.config"; +import { getDefaultOrganization } from "./db/queries/select"; +import { getSessionFromRequest } from "./lib/auth/middleware"; const I18nMiddleware = createI18nMiddleware({ locales: [...languineConfig.locale.targets, languineConfig.locale.source], @@ -8,7 +10,26 @@ const I18nMiddleware = createI18nMiddleware({ }); export async function middleware(request: NextRequest) { - return I18nMiddleware(request); + const i18nResponse = await I18nMiddleware(request); + + // Only proceed with organization check for login path + if (request.nextUrl.pathname.includes("/login")) { + const session = await getSessionFromRequest(); + + if (!session?.user.id) { + return i18nResponse; + } + + const data = await getDefaultOrganization(session?.user.id); + + if (data?.organizations) { + return NextResponse.redirect( + new URL(`/${data.organizations.slug}/default`, request.url), + ); + } + } + + return i18nResponse; } export const config = { diff --git a/apps/web/src/trpc/client.tsx b/apps/web/src/trpc/client.tsx new file mode 100644 index 00000000..1a1914b7 --- /dev/null +++ b/apps/web/src/trpc/client.tsx @@ -0,0 +1,81 @@ +"use client"; +// ^-- to make sure we can mount the Provider from a server component +import type { QueryClient } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { + httpBatchLink, + httpLink, + isNonJsonSerializable, + splitLink, +} from "@trpc/client"; +import { createTRPCReact } from "@trpc/react-query"; +import { useState } from "react"; +import superjson from "superjson"; +import { makeQueryClient } from "./query-client"; +import type { AppRouter } from "./routers/_app"; + +export const trpc = createTRPCReact(); + +let clientQueryClientSingleton: QueryClient; + +function getQueryClient() { + if (typeof window === "undefined") { + // Server: always make a new query client + return makeQueryClient(); + } + // Browser: use singleton pattern to keep the same query client + // biome-ignore lint/suspicious/noAssignInExpressions: + return (clientQueryClientSingleton ??= makeQueryClient()); +} + +function getUrl() { + const base = (() => { + if (typeof window !== "undefined") return ""; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + return "http://localhost:3000"; + })(); + return `${base}/api/trpc`; +} + +export function TRPCProvider( + props: Readonly<{ + children: React.ReactNode; + }>, +) { + // NOTE: Avoid useState when initializing the query client if you don't + // have a suspense boundary between this and the code that may + // suspend because React will throw away the client on the initial + // render if it suspends and there is no boundary + const queryClient = getQueryClient(); + const [trpcClient] = useState(() => + trpc.createClient({ + links: [ + splitLink({ + condition: (op) => isNonJsonSerializable(op.input), + // @ts-ignore - it needs the tarnsformer but it doesn't work with handling form data πŸ€·β€β™‚οΈ + true: httpLink({ + url: getUrl(), + // transformer: superjson, + }), + false: httpBatchLink({ + transformer: superjson, + url: getUrl(), + }), + }), + // httpBatchLink({ + // transformer: superjson, + // url: getUrl(), + // }), + ], + }), + ); + return ( + + + {props.children} + + + + ); +} diff --git a/apps/web/src/trpc/init.ts b/apps/web/src/trpc/init.ts new file mode 100644 index 00000000..09a61875 --- /dev/null +++ b/apps/web/src/trpc/init.ts @@ -0,0 +1,32 @@ +import { getSession } from "@/lib/session"; +import { TRPCError, initTRPC } from "@trpc/server"; +import { cache } from "react"; +import superjson from "superjson"; + +export const createTRPCContext = cache(async () => { + return {}; +}); + +const t = initTRPC.context().create({ + transformer: superjson, +}); + +export const createCallerFactory = t.createCallerFactory; + +// Base router and procedure helpers +export const createTRPCRouter = t.router; +export const baseProcedure = t.procedure; + +export const protectedProcedure = t.procedure.use(async (opts) => { + const session = await getSession(); + + if (!session.data?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return opts.next({ + ctx: { + user: session.data.user, + }, + }); +}); diff --git a/apps/web/src/trpc/query-client.ts b/apps/web/src/trpc/query-client.ts new file mode 100644 index 00000000..4637b84c --- /dev/null +++ b/apps/web/src/trpc/query-client.ts @@ -0,0 +1,24 @@ +import { + QueryClient, + defaultShouldDehydrateQuery, +} from "@tanstack/react-query"; +import superjson from "superjson"; + +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + }, + dehydrate: { + serializeData: superjson.serialize, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === "pending", + }, + hydrate: { + deserializeData: superjson.deserialize, + }, + }, + }); +} diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts new file mode 100644 index 00000000..82577c9d --- /dev/null +++ b/apps/web/src/trpc/routers/_app.ts @@ -0,0 +1,12 @@ +import type { inferRouterOutputs } from "@trpc/server"; +import { createTRPCRouter } from "../init"; +import { organizationRouter } from "./organization"; + +export const appRouter = createTRPCRouter({ + organization: organizationRouter, +}); + +// export type definition of API +export type AppRouter = typeof appRouter; + +export type RouterOutputs = inferRouterOutputs; diff --git a/apps/web/src/trpc/routers/organization.ts b/apps/web/src/trpc/routers/organization.ts new file mode 100644 index 00000000..994a993d --- /dev/null +++ b/apps/web/src/trpc/routers/organization.ts @@ -0,0 +1,121 @@ +import { db } from "@/db"; +import { getAllOrganizationsWithProjects } from "@/db/queries/select"; +import { members, organizations, projects } from "@/db/schema"; +import { createId } from "@paralleldrive/cuid2"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import slugify from "slugify"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "../init"; + +export const organizationRouter = createTRPCRouter({ + getById: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ input }) => { + const org = await db + .select() + .from(organizations) + .where(eq(organizations.id, input.id)) + .get(); + + if (!org) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Organization not found", + }); + } + + return org; + }), + + getAll: protectedProcedure.input(z.void()).query(async () => { + return getAllOrganizationsWithProjects(); + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + userId: z.string(), + }), + ) + .mutation(async ({ input }) => { + const org = await db + .insert(organizations) + .values({ + name: input.name, + slug: `${slugify(input.name, { lower: true })}-${createId().slice(0, 8)}`, + }) + .returning() + .get(); + + if (!org) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create organization", + }); + } + + await db.insert(members).values({ + userId: input.userId, + organizationId: org.id, + role: "owner", + }); + + await db.insert(projects).values({ + name: "Default", + organizationId: org.id, + slug: "default", + }); + + return org; + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string(), + name: z.string().min(1), + logo: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const org = await db + .update(organizations) + .set({ + name: input.name, + logo: input.logo, + }) + .where(eq(organizations.id, input.id)) + .returning() + .get(); + + if (!org) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update organization", + }); + } + + return org; + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const org = await db + .delete(organizations) + .where(eq(organizations.id, input.id)) + .returning() + .get(); + + if (!org) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to delete organization", + }); + } + + return org; + }), +}); diff --git a/apps/web/src/trpc/server.ts b/apps/web/src/trpc/server.ts new file mode 100644 index 00000000..8f91d8e0 --- /dev/null +++ b/apps/web/src/trpc/server.ts @@ -0,0 +1,16 @@ +import "server-only"; // <-- ensure this file cannot be imported from the client + +import { createHydrationHelpers } from "@trpc/react-query/rsc"; +import { cache } from "react"; +import { createCallerFactory, createTRPCContext } from "./init"; +import { makeQueryClient } from "./query-client"; +import { appRouter } from "./routers/_app"; + +// IMPORTANT: Create a stable getter for the query client that +// will return the same client during the same request. +export const getQueryClient = cache(makeQueryClient); +const caller = createCallerFactory(appRouter)(createTRPCContext); +export const { trpc, HydrateClient } = createHydrationHelpers( + caller, + getQueryClient, +); diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 4984a1d9..5bdd55c8 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,3 +1,3 @@ { - "regions": ["fra1", "sfo1", "iad1"] + "regions": ["fra1", "syd1", "sfo1"] } \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 572bc6a1..045dc506 100755 Binary files a/bun.lockb and b/bun.lockb differ