commit c55ab3d49e0b78f2a7910107e1b7b6af09a0d480 Author: Christopher Arraya Date: Sat Jan 13 20:55:51 2024 -0500 initial commit diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fc1e29 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Skalara, Inc. diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..962e4e2 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,21 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import type { NextRequest } from "next/server"; +import type { Database } from "@/types/supabase"; + +export async function GET(request: NextRequest) { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + + if (code) { + const cookieStore = cookies(); + const supabase = createRouteHandlerClient({ + cookies: () => cookieStore, + }); + await supabase.auth.exchangeCodeForSession(code); + } + + return NextResponse.redirect(requestUrl.origin); +} diff --git a/app/auth/login/route.ts b/app/auth/login/route.ts new file mode 100644 index 0000000..3449870 --- /dev/null +++ b/app/auth/login/route.ts @@ -0,0 +1,26 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const formData = await request.json(); + const email = String(formData.email); + const password = String(formData.password); + const supabase = createRouteHandlerClient({ cookies }); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return NextResponse.json({ error }, { status: 401 }); + } + + return NextResponse.json( + { message: "Successfully signed in." }, + { status: 200 } + ); +} diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts new file mode 100644 index 0000000..f6b2d17 --- /dev/null +++ b/app/auth/logout/route.ts @@ -0,0 +1,19 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import type { Database } from "@/types/supabase"; + +export async function POST(request: Request) { + const requestUrl = new URL(request.url); + const cookieStore = cookies(); + const supabase = createRouteHandlerClient({ + cookies: () => cookieStore, + }); + + await supabase.auth.signOut(); + + return NextResponse.redirect(`${requestUrl.origin}/auth`, { + status: 301, + }); +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000..2e531c9 --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,223 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/components/ui/use-toast"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +const authSchema = z.object({ + email: z.string().email("Please enter a valid email"), + password: z.string().min(8, "Password must be at least 8 characters"), +}); + +export default function Auth() { + const { push } = useRouter(); + const { toast } = useToast(); + const form = useForm>({ + resolver: zodResolver(authSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + async function handleSignup(values: z.infer) { + try { + const res = await fetch("/auth/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + + const { error, message } = await res.json(); + + if (error) { + toast({ + variant: "destructive", + title: "Uh oh! Something went wrong.", + description: error, + }); + throw new Error(error); + } else if (message) { + toast({ + title: "Check your email!", + description: message, + }); + } + } catch (error) { + console.error(error); + } + } + + async function handleLogin(values: z.infer) { + try { + const res = await fetch("/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + + const { error } = await res.json(); + + if (error) { + toast({ + variant: "destructive", + title: "Uh oh! Something went wrong.", + description: error.message, + }); + throw new Error(error); + } + + push("/"); + } catch (error) { + console.error(error); + } + } + + return ( +
+ + + Login + Signup + + + + + Login to your account + + Enter your email and password below to login. + + + +
+ + + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + +
+ +
+
+ + + + Create your account + + Enter your email and password below to create an account. + + +
+ + + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + +
+ +
+
+
+
+ ); +} diff --git a/app/auth/signup/route.ts b/app/auth/signup/route.ts new file mode 100644 index 0000000..212d593 --- /dev/null +++ b/app/auth/signup/route.ts @@ -0,0 +1,52 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import { WorkspaceResponse } from "@/types"; +import prisma from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +// add check on beta_testers table to see if user is a beta tester. if they aren't, then don't allow them to sign up + +export async function POST(request: Request) { + const requestUrl = new URL(request.url); + const formData = await request.json(); + const email = String(formData.email); + const password = String(formData.password); + const supabase = createRouteHandlerClient({ cookies }); + + const checkBeta = await prisma.beta_testers.findFirst({ + where: { + email: email, + }, + }); + + if (!checkBeta) { + return NextResponse.json( + { error: "You are not a beta tester" }, + { status: 401 } + ); + } + + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${requestUrl.origin}/auth/callback`, + }, + }); + + if (error) { + console.error(error); + return NextResponse.json( + { error: "Could not authenticate user" }, + { status: 401 } + ); + } + + return NextResponse.json( + { message: "Check email to continue sign in process" }, + { status: 301 } + ); +} diff --git a/app/chat/[taskID]/route.ts b/app/chat/[taskID]/route.ts new file mode 100644 index 0000000..a9cf9e7 --- /dev/null +++ b/app/chat/[taskID]/route.ts @@ -0,0 +1,46 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +export async function GET( + req: NextRequest, + { params: { taskID } }: { params: { taskID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const messages = await prisma.message.findMany({ + where: { + task_id: BigInt(taskID), + }, + orderBy: { + created_at: "asc", + }, + }); + + console.log(messages); + + const res = messages.map((message) => ({ + ...message, + id: String(message.id), + task_id: String(message.task_id), + })); + + return NextResponse.json({ messages: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/chat/add/route.ts b/app/chat/add/route.ts new file mode 100644 index 0000000..462ce4c --- /dev/null +++ b/app/chat/add/route.ts @@ -0,0 +1,48 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const formData = await req.json(); + const role = String(formData.role); + const message = String(formData.content); + const task_id = String(formData.task_id); + console.log("TASK_ID IN CHATADD ===> ", task_id); + + console.log(role, message); + + const res = await prisma.message.create({ + data: { + content: message, + role: role, + task_id: BigInt(task_id), + }, + }); + + const res_data = { + ...res, + id: String(res.id), + task_id: String(res.task_id), + }; + + return NextResponse.json({ message: res_data }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/chat/getTask/route.ts b/app/chat/getTask/route.ts new file mode 100644 index 0000000..98cdbdb --- /dev/null +++ b/app/chat/getTask/route.ts @@ -0,0 +1,40 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// get task by task id as param +export async function POST(req: NextRequest, res: NextResponse) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const task_id = String(req_data.task_id); + + const task = await prisma.task.findUnique({ + where: { + id: BigInt(task_id), + }, + }); + + if (!task) { + return NextResponse.json({ error: "No task found" }, { status: 404 }); + } + + return NextResponse.json({ task }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/chat/route.ts b/app/chat/route.ts new file mode 100644 index 0000000..7433378 --- /dev/null +++ b/app/chat/route.ts @@ -0,0 +1,63 @@ +// app/api/chat/route.ts + +import OpenAI from "openai"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import { NextResponse } from "next/server"; + +// Optional, but recommended: run on the edge runtime. +// See https://vercel.com/docs/concepts/functions/edge-functions +export const runtime = "edge"; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY!, +}); + +export async function POST(req: Request) { + // Extract the `messages` from the body of the request + try { + const { messages, ...body } = await req.json(); + + console.log("BODY", body.taskInfo); + + // console.log("BODY===>", body); + console.log("HERE ARE THE MESSAGES ===>", messages); + + const prompt = `You are a chatbot that helps users with questions specific to project tasks. +- Project Details: + - Name: ${body.projectInfo.name} + - Description: ${body.projectInfo.description} + - Tech Stack: ${body.projectInfo.stack.join(", ")} + +- Feature Context: + - Name: ${body.featureInfo.name} + - Description: ${body.featureInfo.description} + +- Task Context: + - Name: ${body.taskInfo.name} + - Description: ${body.taskInfo.description} + +OPERATION GUIDELINES: + +1. Provide information and answer questions specifically related to the project, feature, or task context provided. +2. Do not give generic answers; tailor responses based on the given context.`; + + messages.unshift({ role: "system", content: prompt }); + console.log("MESSAGES ===>", messages); + + // Request the OpenAI API for the response based on the prompt + const response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo-16k", + stream: true, + messages: messages, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); + } catch (err) { + console.error(err); + return NextResponse.json({ err }, { status: 500 }); + } +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..b64f593 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,14 @@ +import { WorkspaceSelector } from "@/components/workspace-selector"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..d789326 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,7 @@ +export default function Dashboard() { + return ( +
+

Dashboard capabilities coming soon...

+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..773bdf9 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { Inter as FontSans } from "next/font/google"; +import "@/styles/globals.css"; +import { cn } from "@/lib/utils"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/toaster"; + +export const fontSans = FontSans({ + subsets: ["latin"], + variable: "--font-sans", +}); + +export const metadata: Metadata = { + title: "Skalara", + description: "Automated project management for indie developers.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..757159c --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,105 @@ +import Logout from "@/components/logout"; +import GridPattern from "@/components/magicui/grid-pattern"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default async function Home() { + const supabase = createServerComponentClient({ cookies }); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + console.log("BOO no user"); + } + + return ( +
+
+ + +
+
+

+ Simple. Intelligent. Automated. +

+

+ AI-powered project management for indie developers. +

+
+ + +
+
+ +
+
+
+ ); +} diff --git a/app/w/[workspaceID]/delete/route.ts b/app/w/[workspaceID]/delete/route.ts new file mode 100644 index 0000000..b208426 --- /dev/null +++ b/app/w/[workspaceID]/delete/route.ts @@ -0,0 +1,35 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import { WorkspaceResponse } from "@/types"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} +export async function DELETE( + req: NextRequest, + { params: { workspaceID } }: { params: { workspaceID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const parsed_workspace_id = BigInt(workspaceID); + + await prisma.workspace.delete({ + where: { id: parsed_workspace_id }, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/layout.tsx b/app/w/[workspaceID]/layout.tsx new file mode 100644 index 0000000..dd1b097 --- /dev/null +++ b/app/w/[workspaceID]/layout.tsx @@ -0,0 +1,21 @@ +import { WorkspaceSelector } from "@/components/workspace-selector"; +import { WorkspaceSidebar } from "@/components/workspace-sidebar"; +export default function WorkspaceLayout({ + children, + params: { workspaceID }, +}: { + children: React.ReactNode; + params: { workspaceID: string }; +}) { + return ( +
+
+ +
+
+ +
+
{children}
+
+ ); +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/add/route.ts b/app/w/[workspaceID]/p/[projectID]/features/add/route.ts new file mode 100644 index 0000000..8039e5b --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/add/route.ts @@ -0,0 +1,39 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create feature +export async function POST( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: add feature + const req_data = await req.json(); + const features = req_data.features; + console.log("FEATURES ===>", features); + + const added_features = await prisma.feature.createMany({ + data: features, + }); + + return NextResponse.json({ message: "added_features" }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts b/app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts new file mode 100644 index 0000000..958d963 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/gen/deps/route.ts @@ -0,0 +1,109 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { generateFeatureDeps, generateFeatures } from "@/lib/prompts"; +import openai from "@/lib/openai"; +import { randomUUID } from "crypto"; +import { Feature } from "@/types"; + +import { completion } from "zod-gpt"; +import * as z from "zod"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate feature dependencies +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: generate features for project + const req_data = await req.json(); + const project_name = String(req_data.project_name); + const project_description = String(req_data.project_description); + const project_stack = req_data.project_stack; + const features: any = req_data.features; + + const featureUids: readonly [string, ...string[]] = features.map( + (feature: any) => feature.id.toString() + ) as [string, ...string[]]; + + console.log("FEATURE UIDS ===>", featureUids); + + const feature_dep_prompt = generateFeatureDeps( + project_name, + project_description, + project_stack, + features + ); + + const res = await completion(openai, feature_dep_prompt, { + schema: z.object({ + dependencies: z + .array( + z.object({ + uid: z.enum(featureUids).describe("The ID of this feature"), + dependencies: z + .array( + z.object({ + uid: z + .enum(featureUids) + .describe( + "The ID of the feature this feature depends on" + ), + }) + ) + .describe("The ID of the dependencies of the feature"), + }) + ) + .describe("The dependencies of the features"), + }), + }); + + // add dependencies to feature_dependencies table + const convertDependencies = (dependencies: any) => { + let newDependencies: any[] = []; + + dependencies.forEach((dep: any) => { + if (dep.dependencies.length === 0) { + return; // Skip if no dependencies + } + + dep.dependencies.forEach((subDep: any) => { + newDependencies.push({ + feature_id: BigInt(dep.uid), + dependency_id: BigInt(subDep.uid), + }); + }); + }); + + return newDependencies; + }; + + const featureDependencies = convertDependencies(res.data.dependencies); + + // add dependencies to feature_dependencies table + const deps = await prisma.feature_dependencies.createMany({ + data: featureDependencies, + }); + + console.log(deps); + + return NextResponse.json( + { dependencies: res.data.dependencies }, + { status: 200 } + ); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/gen/route.ts b/app/w/[workspaceID]/p/[projectID]/features/gen/route.ts new file mode 100644 index 0000000..8b99961 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/gen/route.ts @@ -0,0 +1,76 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { generateFeatures } from "@/lib/prompts"; +import openai from "@/lib/openai"; +import { randomUUID } from "crypto"; + +import { completion } from "zod-gpt"; +import * as z from "zod"; + +const MIN_FEATURES_PER_PROJECT = 6; +const MAX_FEATURES_PER_PROJECT = 12; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate features for project +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: generate features for project + const req_data = await req.json(); + const project_name = String(req_data.project_name); + const project_description = String(req_data.project_description); + const project_stack = req_data.project_stack; + const qa = req_data.qa; + + console.log(project_name, project_description, project_stack, qa); + + const feature_gen_prompt = generateFeatures( + project_name, + project_description, + project_stack, + qa + ); + + const res = await completion(openai, feature_gen_prompt, { + schema: z.object({ + features: z + .array( + z.object({ + name: z.string().describe("The name of the feature"), + description: z + .string() + .describe("The description of the feature"), + }) + ) + .min(MIN_FEATURES_PER_PROJECT) + .max(MAX_FEATURES_PER_PROJECT), + }), + }); + + const features = res.data.features.map((feature) => ({ + ...feature, + uid: String(randomUUID()), + project_id: String(req_data.project_id), + })); + + console.log(features); + + return NextResponse.json({ features }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts b/app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts new file mode 100644 index 0000000..2e88228 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/gen/tasks/route.ts @@ -0,0 +1,113 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { generateTasks } from "@/lib/prompts"; +import openai from "@/lib/openai"; + +import { completion } from "zod-gpt"; +import * as z from "zod"; + +const MIN_TASKS_PER_FEATURE = 6; +const MAX_TASKS_PER_FEATURE = 12; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate tasks for feature +export async function POST( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const project_name = String(req_data.project_name); + const project_description = String(req_data.project_description); + const project_stack = req_data.project_stack; + + const features = await prisma.feature.findMany({ + where: { + project_id: BigInt(projectID), + }, + include: { + feature_dependencies_feature_dependencies_feature_idTofeature: true, + }, + }); + if (features.length === 0) { + console.error("none"); + return NextResponse.json({ error: "No features found" }, { status: 404 }); + } + const cumulative_tasks: any[] = []; + + for (const feature of features) { + // get all feature dependencies + const feature_deps = + feature.feature_dependencies_feature_dependencies_feature_idTofeature.map( + (dep) => { + const dependencies_of_feature = features.find( + (f) => f.id === dep.dependency_id + ); + + return dependencies_of_feature; + } + ); + + console.log("CURRENT FEATURE ===>", feature); + console.log("FEATURE DEPS ===>", feature_deps); + + const task_gen_prompt = generateTasks( + project_name, + project_description, + project_stack, + feature, + feature_deps + ); + + const res = await completion(openai, task_gen_prompt, { + schema: z.object({ + tasks: z + .array( + z.object({ + name: z.string().describe("The task name"), + description: z.string().describe("The task description"), + priority: z + .enum(["low", "medium", "high"]) + .describe("The task priority"), + order: z + .number() + .describe( + "The order in which the task should be implemented in relation to other tasks" + ), + }) + ) + .min(MIN_TASKS_PER_FEATURE) + .max(MAX_TASKS_PER_FEATURE), + }), + }); + + const tasks = res.data.tasks; + console.log("TASKS ===>", tasks); + // add feature_id to tasks + const tasks_with_feature_id = tasks.map((task: any) => { + return { ...task, feature_id: String(feature.id), status: "backlog" }; + }); + + cumulative_tasks.push(...tasks_with_feature_id); + } + + return NextResponse.json({ tasks: cumulative_tasks }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/features/route.ts b/app/w/[workspaceID]/p/[projectID]/features/route.ts new file mode 100644 index 0000000..ee4a46c --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/features/route.ts @@ -0,0 +1,50 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// get all features for a project +export async function GET( + req: NextRequest, + { + params: { projectID }, + }: { + params: { projectID: string }; + } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const features = await prisma.feature.findMany({ + where: { + project_id: BigInt(projectID), + }, + }); + + if (features.length === 0) { + console.log("none"); + return NextResponse.json({ error: "No features found" }, { status: 404 }); + } + const res = features.map((feature) => ({ + ...feature, + id: String(feature.id), + project_id: String(feature.project_id), + })); + + return NextResponse.json({ features: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/gen/route.ts b/app/w/[workspaceID]/p/[projectID]/gen/route.ts new file mode 100644 index 0000000..947aabd --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/gen/route.ts @@ -0,0 +1,113 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +import * as z from "zod"; +import openai from "@/lib/openai"; +import { completion } from "zod-gpt"; +import { generateProjectQuestions } from "@/lib/prompts"; + +const MIN_QUESTIONS = 1; +const MAX_QUESTIONS = 3; + +type Question = { + question: string; + answer: string; +}; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// generate questions for user about their project +export async function GET( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: generate questions for user about their project + // 1. get project name, description, and stack + console.log("PROJECT ID", projectID); + const project = await prisma.project.findUnique({ + where: { + id: BigInt(projectID), + }, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const { name, description, stack } = project; + + // 2. generate questions + const question_prompt = generateProjectQuestions( + name, + description ? description : "", + stack + ); + + const res = await completion(openai, question_prompt, { + schema: z.object({ + questions: z + .array( + z.string().describe("A question to ask the user about the project") + ) + .min(MIN_QUESTIONS) + .max(MAX_QUESTIONS), + }), + }); + + console.log(res.data.questions); + + return NextResponse.json( + { questions: res.data.questions }, + { status: 200 } + ); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} + +export async function PUT( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const questions: Question[] = req_data; + + const res = await prisma.project.update({ + where: { + id: BigInt(projectID), + }, + data: { + questions, + }, + }); + + return NextResponse.json( + { message: "Completed successfully" }, + { status: 200 } + ); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/layout.tsx b/app/w/[workspaceID]/p/[projectID]/layout.tsx new file mode 100644 index 0000000..47fa199 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/layout.tsx @@ -0,0 +1,10 @@ +import { WorkspaceSidebar } from "@/components/workspace-sidebar"; +export default function WorkspaceLayout({ + children, + params: { workspaceID, projectID }, +}: { + children: React.ReactNode; + params: { workspaceID: string; projectID: string }; +}) { + return
{children}
; +} diff --git a/app/w/[workspaceID]/p/[projectID]/page.tsx b/app/w/[workspaceID]/p/[projectID]/page.tsx new file mode 100644 index 0000000..f140631 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/page.tsx @@ -0,0 +1,254 @@ +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { Database } from "@/types/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { NextResponse, NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { WorkspaceSidebar } from "@/components/workspace-sidebar"; +import { ThemeToggle } from "@/components/theme-toggle"; +import KanbanBoard from "@/components/kanban/board"; +import { Button } from "@/components/ui/button"; +import { GenerateProject } from "@/components/generate-project"; +import Tasks from "@/components/tasks"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { FeatureList } from "@/components/feature-list"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// async function getProject(projectID: string) { +// const supabase = createServerComponentClient({ cookies }); +// const session = await getSession(supabase); + +// if (!session) redirect("/auth"); + +// try { +// // prisma function to get project by projectID if user is part of the project +// const project = await prisma.project.findFirst({ +// where: { +// id: BigInt(projectID), +// profile_project: { +// some: { +// profile_id: session.user.id, +// }, +// }, +// }, +// include: { +// task: true, +// }, +// }); + +// if (!project) { +// return NextResponse.json({ error: "not_found" }, { status: 404 }); +// } + +// const serialized_tasks = project.task.map((task) => ({ +// ...task, +// id: task.id.toString(), +// project_id: task.project_id?.toString(), +// feature_id: task.feature_id?.toString(), +// })); + +// const convertTaskBigIntsToString = (task: any) => { +// return { +// ...task, +// id: task.id.toString(), +// project_id: task.project_id.toString(), +// feature_id: task.feature_id.toString(), +// order: task.order.toString(), +// }; +// }; + +// const res = { +// ...project, +// id: project.id.toString(), +// workspace_id: project.workspace_id.toString(), +// task: serialized_tasks.map(convertTaskBigIntsToString), +// }; + +// console.log("RES", project.task); + +// return NextResponse.json(res, { status: 200 }); +// } catch (err) { +// console.error(err); +// } +// } +async function getProject(projectID: string) { + const supabase = createServerComponentClient({ cookies }); + const session = await getSession(supabase); + + if (!session) redirect("/auth"); + + try { + // const project = await prisma.project.findFirst({ + // where: { + // id: BigInt(projectID), + // profile_project: { + // some: { + // profile_id: session.user.id, + // }, + // }, + // }, + // include: { + // task: { + // include: { + // feature: true, // Assuming 'feature' is correctly named relation + // }, + // }, + // }, + // }); + + // if (!project) { + // return NextResponse.json({ error: "not_found" }, { status: 404 }); + // } + + // // Serialize BigInts and include project and feature information + // const serializedProject = { + // id: project.id.toString(), + // workspace_id: project.workspace_id.toString(), + // created_at: project.created_at, + // name: project.name, + // description: project.description, + // stack: project.stack, + // questions: project.questions, + // tasks: project.task.map(({ feature, ...task }) => ({ + // ...task, + // id: task.id.toString(), + // project_id: task.project_id?.toString(), + // feature_id: feature?.id.toString(), + // feature_name: feature?.name, // Assume feature is nullable + // feature_description: feature?.description, // Assume feature is nullable + // // ...include other task fields that may be BigInt or require serialization + // })), + // }; + + // console.log("Serialized Project", serializedProject); + + // return NextResponse.json(serializedProject, { status: 200 }); + const project = await prisma.project.findFirst({ + where: { + id: BigInt(projectID), + // ... other conditions + }, + include: { + task: { + include: { + feature: true, + }, + }, + }, + }); + + if (!project) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + + // Map tasks to conform to the taskSchema + const tasksWithDetails = project.task.map((task) => { + return { + id: task.id.toString(), + name: task.name, + description: task.description, + project_id: project.id.toString(), + project_name: project.name, + project_description: project.description, + project_stack: project.stack, + feature_id: task.feature_id?.toString() || null, + feature_name: task.feature?.name || null, + feature_description: task.feature?.description || null, + status: task.status, + priority: task.priority || null, + order: task.order, // Convert order to number if it exists + due_date: task.due_date ? task.due_date.toISOString() : null, // Convert to ISO string if due_date exists + assignee: task.assignee || null, + // Add serialization for any other fields that are required by the task schema + }; + }); + + const response = { + id: project.id.toString(), + name: project.name, + description: project.description, + stack: project.stack, + workspace_id: project.workspace_id.toString(), + // ...include other project-level details as needed + tasks: tasksWithDetails, // Include the detailed tasks list + }; + + console.log("Project with Tasks", response); + + return NextResponse.json(response, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: "server_error" }, { status: 500 }); + } +} + +export default async function Project({ + params: { workspaceID, projectID }, +}: { + params: { workspaceID: string; projectID: string }; +}) { + const raw_project = await getProject(projectID); + console.log("RAW PROJECT", raw_project); + if (raw_project?.status == 404) { + return ( +
+

Project not found.

+
+ ); + } + + if (!raw_project) { + return ( +
+

Something went wrong.

+
+ ); + } + + const project = await raw_project.json(); + console.log("AWAITED PROJECT", project); + + return ( +
+
+

{project.name}

+
+ + + + + Features + + + + + +
+
+ + {/* */} +
+ +
+
+ ); +} diff --git a/app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts b/app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts new file mode 100644 index 0000000..c01c94f --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/tasks/add/route.ts @@ -0,0 +1,49 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create task +export async function POST( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + // TODO: add task + const req_data = await req.json(); + const tasks = req_data.tasks; + + console.log("TASKS IN ADD TASKS ROUTE ===>", tasks); + + const tasks_with_project_id = tasks.map((task: any) => { + return { + ...task, + project_id: BigInt(projectID), + feature_id: BigInt(task.feature_id), + }; + }); + + await prisma.task.createMany({ + data: tasks_with_project_id, + skipDuplicates: true, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/tasks/assign/route.ts b/app/w/[workspaceID]/p/[projectID]/tasks/assign/route.ts new file mode 100644 index 0000000..3040173 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/tasks/assign/route.ts @@ -0,0 +1,44 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// assign task to user using profile_task junction table +export async function POST( + req: NextRequest, + { + params: { workspaceID, projectID }, + }: { params: { workspaceID: string; projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const { task_id, user_id } = req_data; + + const parsed_task_id = BigInt(task_id); + + await prisma.profile_task.create({ + data: { + profile_id: user_id, + task_id: parsed_task_id, + }, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/tasks/delete/route.ts b/app/w/[workspaceID]/p/[projectID]/tasks/delete/route.ts new file mode 100644 index 0000000..e6e8fa7 --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/tasks/delete/route.ts @@ -0,0 +1,38 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +export async function DELETE( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const { id } = req_data; + + const parsed_id = BigInt(id); + + await prisma.task.delete({ + where: { id: parsed_id }, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/[projectID]/tasks/gen/route.ts b/app/w/[workspaceID]/p/[projectID]/tasks/gen/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/w/[workspaceID]/p/[projectID]/tasks/mutate/route.ts b/app/w/[workspaceID]/p/[projectID]/tasks/mutate/route.ts new file mode 100644 index 0000000..936642f --- /dev/null +++ b/app/w/[workspaceID]/p/[projectID]/tasks/mutate/route.ts @@ -0,0 +1,42 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// update task based on whatever is passed in +export async function PUT( + req: NextRequest, + { params: { projectID } }: { params: { projectID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const { id, ...task_updates } = req_data; + + console.log("TASKS IN MUTATE TASKS ROUTE ===>", task_updates); + + const parsed_id = BigInt(id); + + await prisma.task.update({ + where: { id: parsed_id }, + data: task_updates, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/p/route.ts b/app/w/[workspaceID]/p/route.ts new file mode 100644 index 0000000..3d43a57 --- /dev/null +++ b/app/w/[workspaceID]/p/route.ts @@ -0,0 +1,58 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { ProjectResponse } from "@/types"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create project +export async function POST( + req: NextRequest, + { params: { workspaceID } }: { params: { workspaceID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const name = String(req_data.name); + const description = String(req_data.description); + const stack: string[] = req_data.stack; + + console.log(name, description, stack, workspaceID, session.user.id); + + const project = await prisma.project.create({ + data: { + name, + description, + stack, + workspace_id: BigInt(workspaceID), + profile_project: { + create: { + profile_id: session.user.id, + }, + }, + }, + }); + + const res: ProjectResponse = { + ...project, + id: String(project.id), + workspace_id: String(project.workspace_id), + }; + + return NextResponse.json({ project: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/page.tsx b/app/w/[workspaceID]/page.tsx new file mode 100644 index 0000000..75f7122 --- /dev/null +++ b/app/w/[workspaceID]/page.tsx @@ -0,0 +1,73 @@ +import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; +import { Database } from "@/types/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { NextResponse, NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { WorkspaceSidebar } from "@/components/workspace-sidebar"; +import { Button } from "@/components/ui/button"; +import { CreateProject } from "@/components/create-project"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +async function getWorkspace(workspaceID: string) { + const supabase = createServerComponentClient({ cookies }); + const session = await getSession(supabase); + + if (!session) redirect("/auth"); + + try { + // TODO: get workspace by workspaceID if user is part of the workspace + return NextResponse.json({}, { status: 200 }); + } catch (err: any) { + console.error(err); + return NextResponse.json( + { error: "Something went wrong." }, + { status: 500 } + ); + } +} + +export default async function Workspace({ + params: { workspaceID }, +}: { + params: { workspaceID: string }; +}) { + const raw_workspace = await getWorkspace(workspaceID); + const workspace = await raw_workspace.json(); + + if (workspace.error == "not_found") { + return ( +
+

Workspace not found.

+
+ ); + } else if (workspace.error == "unauthorized") { + return ( +
+

You are not a member of this workspace.

+
+ ); + } else if (workspace.error) { + return ( +
+

Something went wrong.

+
+ ); + } + + return ( +
+ {/* +

Workspace Page

*/} +
+ +
+
+ ); +} diff --git a/app/w/[workspaceID]/user/add/route.ts b/app/w/[workspaceID]/user/add/route.ts new file mode 100644 index 0000000..505bc02 --- /dev/null +++ b/app/w/[workspaceID]/user/add/route.ts @@ -0,0 +1,70 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { ProjectResponse } from "@/types"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// function that takes in email, checks if there is a user associated. if there is, get the user id and add it to the profile_workspace junction table. +export async function POST( + req: NextRequest, + { params: { workspaceID } }: { params: { workspaceID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const email = String(req_data.email); + + const user = await prisma.profile.findFirst({ + where: { + email, + }, + }); + + if (!user) { + return NextResponse.json( + { error: "User does not exist" }, + { status: 500 } + ); + } + + await prisma.profile_workspace.create({ + data: { + profile_id: user.id, + workspace_id: BigInt(workspaceID), + }, + }); + + const projects = await prisma.project.findMany({ + where: { + workspace_id: BigInt(workspaceID), + }, + }); + + // Add the user to all projects within the workspace + for (const project of projects) { + await prisma.profile_project.create({ + data: { + profile_id: user.id, + project_id: project.id, + }, + }); + } + + return NextResponse.json({ message: "Success!" }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/[workspaceID]/user/fetch/route.ts b/app/w/[workspaceID]/user/fetch/route.ts new file mode 100644 index 0000000..568d16d --- /dev/null +++ b/app/w/[workspaceID]/user/fetch/route.ts @@ -0,0 +1,57 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import prisma from "@/lib/prisma"; +import { ProjectResponse } from "@/types"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// function that takes in workspaceID and gets all profile ids associated with that workspace id in the profile_workspace junction table. then, get all the profile emails associated with those profile ids. return the emails as an array. +export async function GET( + req: NextRequest, + { params: { workspaceID } }: { params: { workspaceID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const profile_workspaces = await prisma.profile_workspace.findMany({ + where: { + workspace_id: BigInt(workspaceID), + }, + }); + + const profile_ids = profile_workspaces.map((profile_workspace) => { + return profile_workspace.profile_id; + }); + + const profiles = await prisma.profile.findMany({ + where: { + id: { + in: profile_ids, + }, + }, + }); + + // get all emails and profile ids + const emails = profiles.map((profile) => { + return { + email: profile.email, + id: profile.id, + }; + }); + + return NextResponse.json({ emails }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/app/w/route.ts b/app/w/route.ts new file mode 100644 index 0000000..0741665 --- /dev/null +++ b/app/w/route.ts @@ -0,0 +1,73 @@ +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { Database } from "@/types/supabase"; +import { WorkspaceResponse } from "@/types"; +import prisma from "@/lib/prisma"; + +async function getSession(supabase: any) { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session; +} + +// create workspace +export async function POST(req: NextRequest) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const req_data = await req.json(); + const name = String(req_data.name); + const description = String(req_data.description); + + const workspace = await prisma.workspace.create({ + data: { + name, + description, + profile_workspace: { + create: { + profile_id: session.user.id, + }, + }, + }, + }); + + const res: WorkspaceResponse = { + ...workspace, + id: String(workspace.id), + }; + + return NextResponse.json({ workspace: res }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} + +// delete workspace +export async function DELETE( + req: NextRequest, + { params: { workspaceID } }: { params: { workspaceID: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + const session = await getSession(supabase); + + if (!session) return NextResponse.redirect("/auth"); + + try { + const parsed_workspace_id = BigInt(workspaceID); + + await prisma.workspace.delete({ + where: { id: parsed_workspace_id }, + }); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error(err); + return NextResponse.json({ error: err }, { status: 500 }); + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..7681c2f --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/chat.tsx b/components/chat.tsx new file mode 100644 index 0000000..11668da --- /dev/null +++ b/components/chat.tsx @@ -0,0 +1,151 @@ +"use client"; +import { useEffect, useState, useRef } from "react"; +import { Send } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +import { Input } from "@/components/ui/input"; + +import { useChat } from "ai/react"; + +export default function Chat({ projectInfo, featureInfo, taskInfo }: any) { + // console.log("projectInfo", projectInfo); + // console.log("featureInfo", featureInfo); + // console.log("taskInfo", taskInfo); + + const { messages, input, handleInputChange, handleSubmit, setMessages } = + useChat({ + body: { + projectInfo, + featureInfo, + taskInfo, + }, + api: "/chat", + onResponse: async (res) => { + try { + console.log("RESPONSE ===>", res); + const task_id = taskInfo.task_id; + const data = await fetch(`/chat/add`, { + method: "POST", + body: JSON.stringify({ + content: input, + role: "user", + task_id: task_id, + }), + }); + } catch (err) { + console.error(err); + } + }, + onFinish: async (message) => { + try { + console.log("FINISH ===>", message); + const data = await fetch(`/chat/add`, { + method: "POST", + body: JSON.stringify({ + content: message.content, + role: "assistant", + task_id: taskInfo.task_id, + }), + }); + } catch (err) { + console.error(err); + } + }, + onError: (err) => { + console.error("CHAT HOOK ERROR ===>", err); + }, + }); + + useEffect(() => { + async function getMessages() { + try { + const res = await fetch(`/chat/${taskInfo.task_id}`); + const data = await res.json(); + console.log("CLIENT RAW RES ===>", data); + setMessages(data.messages); + return data; + } catch (err) { + console.error(err); + } + } + getMessages(); + }, [setMessages, taskInfo.task_id]); + + const scrollRef = useRef(null); + + useEffect(() => { + // Function to scroll to the bottom of the chat + // add typing + + const scrollToBottom = () => { + const scroll = scrollRef.current as any; + if (scroll) { + scroll.scrollTop = scroll.scrollHeight; + } + }; + + // Call scrollToBottom whenever messages change + scrollToBottom(); + }, [messages]); + + const inputLength = input.trim().length; + + return ( + <> + + +

Chat with this task.

+
+ + + +
+ {messages.map((message, index) => ( +
+ {message.content} +
+ ))} +
+
+
+ + +
+ + +
+
+
+ + ); +} diff --git a/components/create-feature.tsx b/components/create-feature.tsx new file mode 100644 index 0000000..3f5992c --- /dev/null +++ b/components/create-feature.tsx @@ -0,0 +1,60 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const createFeatureSchema = z.object({ + name: z.string().min(2, { + message: "Feature name must be at least 2 characters.", + }), + description: z.string().min(2, { + message: "Feature description must be at least 2 characters.", + }), +}); + +export function CreateFeature() { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(createFeatureSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function createFeature(values: z.infer) { + try { + // TODO: create feature + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating the feature.", + description: err.error, + }); + } + } + + return ( +
+

Create Feature

+
+ ); +} diff --git a/components/create-project.tsx b/components/create-project.tsx new file mode 100644 index 0000000..72286e5 --- /dev/null +++ b/components/create-project.tsx @@ -0,0 +1,203 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter, useParams } from "next/navigation"; +import { useState } from "react"; + +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { cn } from "@/lib/utils"; + +const createProjectSchema = z.object({ + name: z.string().min(2, { + message: "Name must be at least 2 characters.", + }), + description: z.string().min(2, { + message: "Description must be at least 2 characters.", + }), + stack: z.array(z.string()).min(1, { + message: "Project tech stack must have at least one item.", + }), +}); + +export function CreateProject({ className }: { className?: string }) { + const { toast } = useToast(); + const router = useRouter(); + const params = useParams(); + + const [stackInput, setStackInput] = useState(""); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [formStep, setFormStep] = useState(0); + + const form = useForm>({ + resolver: zodResolver(createProjectSchema), + defaultValues: { + name: "", + description: "", + stack: [], + }, + }); + + const { setValue } = form; + + async function createProject(values: z.infer) { + try { + const res = await fetch(`/w/${params.workspaceID}/p`, { + method: "POST", + body: JSON.stringify(values), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message); + } + + const { project } = await res.json(); + + toast({ + variant: "default", + title: "Project created successfully!", + description: `Project ${project.name} was created successfully.`, + }); + router.push(`/w/${params.workspaceID}/p/${project.id}`); + router.refresh(); + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating your project.", + description: err.error, + }); + } + } + + const keyHandler = (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === "Tab") && stackInput !== "") { + e.preventDefault(); + setValue("stack", [...form.getValues("stack"), stackInput]); + setStackInput(""); + } + }; + + return ( + + + + + + + Create Project + +
+ + ( + + Name + + + + This is your project name. + + + )} + /> + ( + + Description + + + + + This is your project description. + + + + )} + /> + ( + + Tech Stack + + setStackInput(e.target.value)} + onKeyDown={keyHandler} + /> + + + This is your project tech stack. + +
+ {form.getValues("stack").map((technology) => ( + + {technology} + + setValue( + "stack", + form + .getValues("stack") + .filter((s) => s !== technology) + ) + } + /> + + ))} +
+ +
+ )} + /> + + + + + +
+
+ ); +} diff --git a/components/create-task.tsx b/components/create-task.tsx new file mode 100644 index 0000000..fe1ac7f --- /dev/null +++ b/components/create-task.tsx @@ -0,0 +1,63 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const createTaskSchema = z.object({ + name: z.string().min(2, { + message: "Task name must be at least 2 characters.", + }), + description: z + .string() + .min(2, { + message: "Task description must be at least 2 characters.", + }) + .optional(), +}); + +export function CreateTask() { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(createTaskSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function createTask(values: z.infer) { + try { + // TODO: create task + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating the task.", + description: err.error, + }); + } + } + + return ( +
+

Create Task

+
+ ); +} diff --git a/components/create-workspace.tsx b/components/create-workspace.tsx new file mode 100644 index 0000000..43e6023 --- /dev/null +++ b/components/create-workspace.tsx @@ -0,0 +1,146 @@ +"use client"; +import { useToast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; + +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PlusIcon } from "@radix-ui/react-icons"; + +const createWorkspaceSchema = z.object({ + name: z.string().min(2, { + message: "Workspace name must be at least 2 characters.", + }), + description: z + .string() + .min(2, { + message: "Workspace description must be at least 2 characters.", + }) + .optional(), +}); + +export function CreateWorkspace() { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(createWorkspaceSchema), + defaultValues: { + name: "", + description: "", + }, + }); + + async function createWorkspace( + values: z.infer + ) { + try { + const res = await fetch("/w", { + method: "POST", + body: JSON.stringify(values), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message); + } + + const { workspace } = await res.json(); + + toast({ + variant: "default", + title: "Workspace created successfully!", + description: `Workspace ${workspace.name} was created successfully.`, + }); + router.push(`/w/${workspace.id}`); + router.refresh(); + } catch (err: any) { + console.error(err); + toast({ + variant: "destructive", + title: "Uh oh! There was an error creating the workspace.", + description: err.error, + }); + } + } + + return ( + + + + + + + Create Workspace + +
+ + ( + + Name + + + + + This is your workspace name. + + + + )} + /> + ( + + Description + + + + + This is your workspace description. + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/components/feature-card.tsx b/components/feature-card.tsx new file mode 100644 index 0000000..64258b4 --- /dev/null +++ b/components/feature-card.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; + +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; + +type Feature = { uid: string; name: string; description: string }; +export function FeatureCard({ + feature, + features, + setFeatures, +}: { + feature: Feature; + features: Feature[]; + setFeatures: React.Dispatch>; +}) { + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(feature.name); + const [description, setDescription] = useState(feature.description); + + const toggleEdit = () => { + setIsEditing(!isEditing); + }; + + const saveChanges = () => { + setFeatures( + features.map((_feature) => { + if (_feature.uid === feature.uid) { + return { ..._feature, name: name, description: description }; + } + return _feature; + }) + ); + toggleEdit(); + }; + + return ( + + {isEditing ? ( + <> + + + setName(e.target.value)} + /> + + + +