feat: initial collaboration implementation

This commit is contained in:
2024-12-22 23:43:27 +01:00
parent c5f2dce36e
commit 737ffca4bc
21 changed files with 489 additions and 30 deletions

View File

@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"input-otp": "^1.4.1",
"ioredis": "^5.4.1",
"lucia": "^3.1.1",
"lucide-react": "^0.368.0",

View File

@@ -0,0 +1,24 @@
-- thanks ai
-- Step 1: Create the new UserProject table
CREATE TABLE "UserProject" (
"userId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
CONSTRAINT "UserProject_pkey" PRIMARY KEY ("userId","projectId")
);
-- Step 2: Insert existing userId and projectId pairs into the UserProject table
INSERT INTO "UserProject" ("userId", "projectId")
SELECT "userId", "id" FROM "Project" WHERE "userId" IS NOT NULL;
-- Step 3: Drop the foreign key constraint and the userId column from the Project table
ALTER TABLE "Project" DROP CONSTRAINT "Project_userId_fkey";
ALTER TABLE "Project" DROP COLUMN "userId";
-- Step 4: Add foreign key constraints to the UserProject table
ALTER TABLE "UserProject" ADD CONSTRAINT "UserProject_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "UserProject" ADD CONSTRAINT "UserProject_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Step 5: Drop the unnecessary _UserProjects table and its constraints
DROP TABLE IF EXISTS "_UserProjects";

View File

@@ -0,0 +1,40 @@
/*
Warnings:
- A unique constraint covering the columns `[inviteCode]` on the table `Project` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "inviteCode" TEXT NOT NULL DEFAULT floor(random() * 90000000 + 10000000)::text;
-- CreateTable
CREATE TABLE "_UserProjects" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_UserProjects_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_UserProjects_B_index" ON "_UserProjects"("B");
-- CreateIndex
CREATE INDEX "Feedback_projectId_idx" ON "Feedback"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "Project_inviteCode_key" ON "Project"("inviteCode");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "UserProject_userId_idx" ON "UserProject"("userId");
-- CreateIndex
CREATE INDEX "UserProject_projectId_idx" ON "UserProject"("projectId");
-- AddForeignKey
ALTER TABLE "_UserProjects" ADD CONSTRAINT "_UserProjects_A_fkey" FOREIGN KEY ("A") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserProjects" ADD CONSTRAINT "_UserProjects_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Project" ALTER COLUMN "inviteCode" SET DEFAULT floor(random() * 90000000 + 10000000)::text;
-- AlterTable
ALTER TABLE "UserProject" ADD COLUMN "isOwner" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -14,12 +14,13 @@ datasource db {
}
model User {
id String @id @default(cuid())
githubId String @unique
id String @id @default(cuid())
githubId String @unique
username String
installations String[]
projects Project[]
projects Project[] @relation("UserProjects")
sessions Session[]
UserProject UserProject[]
}
model Session {
@@ -27,6 +28,8 @@ model Session {
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
@@index([userId])
}
model Project {
@@ -37,17 +40,32 @@ model Project {
customData String[]
rateLimitReq Int @default(5)
rateLimitTime Int @default(60)
// 8 digit random number
inviteCode String @unique @default(dbgenerated("floor(random() * 90000000 + 10000000)::text"))
userId String
user User @relation(fields: [userId], references: [id])
feedback Feedback[]
users User[] @relation("UserProjects")
feedback Feedback[]
UserProject UserProject[]
}
model UserProject {
userId String
projectId String
isOwner Boolean @default(false)
user User @relation(fields: [userId], references: [id])
project Project @relation(fields: [projectId], references: [id])
@@id([userId, projectId])
@@index([userId])
@@index([projectId])
}
model Feedback {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
message String
customData String
projectId String
project Project @relation(fields: [projectId], references: [id])
projectId String
project Project @relation(fields: [projectId], references: [id])
@@index([projectId])
}

View File

@@ -8,7 +8,11 @@ export default async function Page() {
const { user } = await validateRequest();
const db = await prisma.project.findMany({
where: {
userId: user!.id,
UserProject: {
some: {
userId: user!.id,
},
},
},
});
if (db.length === 0) {
@@ -16,9 +20,16 @@ export default async function Page() {
<div className="text-center flex flex-col gap-4 pt-4">
<h2>No projects found</h2>
<p className="text-muted-foreground">Create a project to get started</p>
<Link href="/create">
<Button size={'sm'}>Create Project</Button>
</Link>
<div className="flex justify-center gap-5">
<Link href="/create">
<Button size={'sm'}>Create</Button>
</Link>
<Link href="/join">
<Button size={'sm'} variant={'secondary'}>
Join
</Button>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use server';
import { validateRequest } from "@/lib/auth";
import prisma from "@/lib/db";
export async function join(code: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, message: "Unauthorized" };
}
const numCode = parseInt(code);
if (isNaN(numCode) || code.length !== 8) {
return { success: false, message: "Invalid code" };
}
const project = await prisma.project.findFirst({
where: { inviteCode: code },
include: {
UserProject: {
where: { userId: user.id },
}
}
});
if (!project) {
return { success: false, message: "Project not found" };
}
if (project.UserProject.length > 0) {
return { success: false, message: "Already joined project" };
}
await prisma.project.update({
where: { id: project.id },
data: { UserProject: { create: { userId: user.id } } },
});
return { success: true, message: "Joined project", id: project.id };
}

View File

@@ -0,0 +1,61 @@
'use client';
import { Button } from '@/components/ui/button';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { REGEXP_ONLY_DIGITS } from 'input-otp';
import React from 'react';
import { join } from './action';
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
export default function Page() {
const router = useRouter();
const [value, setValue] = React.useState('');
const [loading, setLoading] = React.useState(false);
return (
<div className="flex flex-col items-center justify-center pt-10 gap-5">
<h2>Join another user&apos;s project!</h2>
<InputOTP
maxLength={8}
pattern={REGEXP_ONLY_DIGITS}
onChange={(val) => setValue(val)}
value={value}
disabled={loading}
>
<InputOTPGroup>
{/* THIS DOESNT WORK:
{Array.from({ length: 8 }).map((_, i) => (
<InputOTPSlot key={i + 1} index={i + 1} />
))}
SO HERE'S BAD CODE YOU ARE WELCOME
*/}
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
</InputOTP>
<Button
onClick={() => {
setLoading(true);
join(value).then((res) => {
setLoading(false);
if (res.success) {
toast.success('Joined project!');
router.push(`/project/${res.id}`);
} else {
toast.error(res.message);
}
});
}}
loading={loading}
>
Let&apos;s go!
</Button>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
const { id } = await params;
const { user } = await validateRequest();
const project = await prisma.project.findFirst({
where: { id, userId: user!.id },
where: { id, UserProject: { some: { userId: user!.id } } },
include: { feedback: true },
});
@@ -97,11 +97,9 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
{Object.entries(JSON.parse(feedback.customData)).map(([key, value]) => (
<TableCell key={key}>{value as string}</TableCell>
))}
<TableCell className='flex gap-2'>
<TableCell className="flex gap-2">
<FeedbackView feedback={feedback} />
{project.github && (
<GithubIssueCreate project={project} feedback={feedback} />
)}
{project.github && <GithubIssueCreate project={project} feedback={feedback} />}
</TableCell>
</TableRow>
))}

View File

@@ -6,7 +6,13 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
const { id } = await params;
const { user } = await validateRequest();
const project = await prisma.project.findFirst({
where: { id, userId: user!.id },
where: { id, UserProject: { some: { userId: user!.id } } },
include: {
UserProject: {
where: { projectId: id },
include: { user: true },
},
},
});
if (!project) {
return <h1>Project not found</h1>;

View File

@@ -0,0 +1,24 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useState } from 'react';
export default function InviteCodeViewer({ code }: { code: string }) {
const [hover, setHover] = useState(false);
return (
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="email">Invite code</Label>
<Input
type="text"
id="inviteCode"
placeholder="Invite code"
className={hover ? '' : 'blur-sm'}
onChange={() => {}}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
value={code}
/>
</div>
);
}

View File

@@ -18,6 +18,7 @@ import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher"
export const links = [
{ href: '/dashboard', name: 'Dashboard' },
{ href: '/create', name: 'Create' },
{ href: '/join', name: 'Join' },
]
function NavbarLinks() {

View File

@@ -2,7 +2,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { Project } from '@prisma/client';
import type { Project, User, UserProject } from '@prisma/client';
import { UniversalForm } from '../UniversalForm/UniversalForm';
import {
customData,
@@ -23,8 +23,10 @@ import {
import GithubRepoChooser from '../GithubRepoChooser/GithubRepoChooser';
import React from 'react';
import Link from 'next/link';
import InviteCodeViewer from '../InviteCodeViewer/InviteCodeViewer';
import ProjectTeamUsers from '../ProjectTeamUsers/ProjectTeamUsers';
export default function ProjectSettings(project: Project) {
export default function ProjectSettings(project: ProjectWithUsers) {
const [ghRepo, setGhRepo] = React.useState('');
const [hasSubmitted, setHasSubmitted] = React.useState(false);
const apiUrl = `https://${window.location.hostname}/api/feedback/${project.id}`;
@@ -121,6 +123,41 @@ export default function ProjectSettings(project: Project) {
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Your team</CardTitle>
<CardDescription>Invite people to join the project!</CardDescription>
</CardHeader>
<CardContent>
<InviteCodeViewer code={project.inviteCode} />
<ProjectTeamUsers UserProject={project.UserProject} />
</CardContent>
</Card>
{/* <Card>
<CardHeader>
<CardTitle>Project Deletion</CardTitle>
<CardDescription>Permanently delete your project</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
This action is irreversible. All feedback and settings will be lost.
</p>
<UniversalForm
fields={[
{
name: 'id',
label: 'ID',
type: 'hidden',
value: project.id,
},
]}
schemaName={'deleteProject'}
action={editProject}
submitText="Delete Project"
submitClassname="!mt-0"
/>
</CardContent>
</Card> */}
</div>
</TabsContent>
@@ -297,3 +334,9 @@ function stripIndents(strings: TemplateStringsArray, ...values: any[]) {
.join('\n')
.trim();
}
interface ProjectWithUsers extends Project {
UserProject: (UserProject & {
user: User;
})[];
}

View File

@@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import type { User, UserProject } from '@prisma/client';
import { useSession } from '@/lib/providers/SessionProvider';
import { deleteProjectTeamUser } from './delete';
import { toast } from 'sonner';
export default function ProjectTeamUsers(userProject: ProjectTeamUsersProps) {
const [users, setUsers] = useState<
(UserProject & {
user: User;
})[]
>(userProject.UserProject);
const [loading, isLoading] = useState<string[]>([]);
const { user: currentUser } = useSession();
const handleDelete = (userId: string) => {
isLoading([...loading, userId]);
deleteProjectTeamUser(userId, userProject.UserProject[0].projectId).then((res) => {
if (res.success) {
toast.success(res.message);
setUsers(users.filter((user) => user.userId !== userId));
} else {
toast.error(res.message);
}
isLoading(loading.filter((id) => id !== userId));
});
};
return (
<ul className="space-y-2 pt-5">
{users.map((user) => (
<li
key={user.userId}
className="flex items-center justify-between p-3 rounded-lg shadow bg-accent"
>
<div className="flex items-center space-x-3">
<Avatar>
<AvatarImage
src={`https://github.com/${user.user.username}.png`}
alt={user.user.username}
/>
<AvatarFallback>{user.user.username.toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{user.user.username}</span>
</div>
<Button
variant="destructive"
size="icon"
onClick={() => handleDelete(user.userId)}
aria-label={`Delete ${user.user.username}`}
disabled={user.isOwner || user.userId === currentUser?.id}
loading={loading.includes(user.userId)}
>
{!loading.includes(user.userId) && <Trash2 className="h-4 w-4" />}
</Button>
</li>
))}
</ul>
);
}
interface ProjectTeamUsersProps {
UserProject: (UserProject & {
user: User;
})[];
}

View File

@@ -0,0 +1,24 @@
'use server';
import { validateRequest } from "@/lib/auth";
import prisma from "@/lib/db";
export async function deleteProjectTeamUser(userId: string, projectId: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, message: 'Unauthorized' };
}
const userProject = await prisma.userProject.findFirst({
where: { userId, projectId },
include: { user: true },
});
if (!userProject) {
return { success: false, message: 'User not found' };
}
await prisma.userProject.deleteMany({
where: { userId, projectId },
});
return { success: true, message: `User ${userProject.user.username} removed.` };
}

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -1,5 +1,5 @@
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { Lucia, Session, User } from 'lucia';
import { Lucia } from 'lucia';
import prisma from '../db';
import { cache } from 'react';
import { cookies } from 'next/headers';

View File

@@ -27,9 +27,10 @@ export async function create(prev: any, formData: FormData) {
const dbCreate = await prisma.project.create({
data: {
...zod.data,
user: {
connect: {
id: user.id,
UserProject: {
create: {
userId: user.id,
isOwner: true,
},
},
},
@@ -141,7 +142,14 @@ export async function githubTestIssue(prev: any, formData: FormData) {
id: zod.data.id,
},
include: {
user: true,
UserProject: {
where: {
userId: user.id,
},
include: {
user: true,
},
}
},
});
if (!project) {
@@ -152,7 +160,7 @@ export async function githubTestIssue(prev: any, formData: FormData) {
const [owner, repo] = project.github!.split('/').slice(-2);
let issueCreated = false;
for (const installationId of project.user.installations) {
for (const installationId of project.UserProject[0].user.installations) {
if (issueCreated) break;
const installation = await octokitApp.getInstallationOctokit(Number(installationId));
@@ -199,7 +207,14 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
id: zod.data.project,
},
include: {
user: true,
UserProject: {
where: {
userId: user.id,
},
include: {
user: true,
}
},
},
});
if (!project) {
@@ -209,7 +224,7 @@ export async function githubCreateIssue(prev: any, formData: FormData) {
try {
const [owner, repo] = project.github!.split('/').slice(-2);
for (const installationId of project.user.installations) {
for (const installationId of project.UserProject[0].user.installations) {
const installation = await octokitApp.getInstallationOctokit(Number(installationId));
const getRepo = await installation
.request('GET /repos/{owner}/{repo}', {

View File

@@ -67,10 +67,15 @@ const config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
},

View File

@@ -3579,6 +3579,11 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
input-otp@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.1.tgz#bc22e68b14b1667219d54adf74243e37ea79cf84"
integrity sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==
internal-slot@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802"