Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 19 additions & 24 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,17 @@ datasource db {
}

model User {
id String @id @default(cuid())
id String @id @default(cuid())
name String?
email String @unique
email String @unique
password String?
image String?
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

chatrooms Chatroom[]
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
chatrooms Chatroom[]
sessions Session[]

@@map("users")
}
Expand All @@ -36,15 +35,14 @@ model Account {
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
id_token String?
session_state String?

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@unique([provider, providerAccountId])
@@map("accounts")
Expand All @@ -55,8 +53,7 @@ model Session {
sessionToken String @unique
userId String
expires DateTime

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@map("sessions")
}
Expand All @@ -72,32 +69,31 @@ model VerificationToken {

model Chatroom {
id String @id @default(uuid())
roomUrl String? @unique
title String
createdAt DateTime @default(now())
roomUrl String? @unique
userId String?
user User? @relation(fields: [userId], references: [id])
messages Message[]

user User? @relation(fields: [userId], references: [id])

@@unique([title, userId], name: "unique_title_per_user")
@@map("chatrooms")
}

model Message {
id String @id @default(uuid())
chatroomId String
alias String
message String
timestamp DateTime @default(now())
chatroomId String
linkPreviews LinkPreview[]

chatroom Chatroom @relation(fields: [chatroomId], references: [id])
chatroom Chatroom @relation(fields: [chatroomId], references: [id])

@@map("messages")
}

model LinkPreview {
id String @id @default(uuid())
id String @id @default(uuid())
messageId String
url String
title String?
Expand All @@ -107,8 +103,7 @@ model LinkPreview {
favicon String?
domain String?
createdAt DateTime @default(now())

message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)

@@map("link_previews")
}
62 changes: 62 additions & 0 deletions src/app/api/rooms/check-name/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

export async function POST(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Get user from database
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});

if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

// Parse and validate input
const { title } = await request.json();

if (!title || typeof title !== "string") {
return NextResponse.json(
{ error: "Title is required and must be a string" },
{ status: 400 }
);
}

const trimmedTitle = title.trim();

if (trimmedTitle.length === 0) {
return NextResponse.json(
{ error: "Title cannot be empty" },
{ status: 400 }
);
}

// Check if user already has a room with this title
const existingRoom = await prisma.chatroom.findFirst({
where: {
title: trimmedTitle,
userId: user.id,
},
});

return NextResponse.json({
available: !existingRoom,
title: trimmedTitle,
});

} catch (error) {
console.error("Error checking room name availability:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
1 change: 0 additions & 1 deletion src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ export default function Home() {
setShowRoomForm(false);
} catch (error) {
console.error("Error creating room:", error);
alert("Failed to create room. Please try again.");
} finally {
setCreatingRoom(false);
}
Expand Down
72 changes: 68 additions & 4 deletions src/components/RoomCreationForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { checkRoomNameAvailability } from "@/lib/roomNameValidation";

interface RoomCreationFormProps {
onSubmit: (title: string) => Promise<void>;
Expand All @@ -12,17 +13,62 @@ export default function RoomCreationForm({
isLoading = false,
}: RoomCreationFormProps) {
const [roomTitle, setRoomTitle] = useState("");
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [isAvailable, setIsAvailable] = useState<boolean | null>(null);

// Debounced validation function
const validateRoomName = useCallback(async (title: string) => {
if (!title.trim()) {
setValidationError(null);
setIsAvailable(null);
return;
}

setIsValidating(true);
setValidationError(null);

try {
const result = await checkRoomNameAvailability(title.trim());

if (result.error) {
setValidationError(result.error);
setIsAvailable(false);
} else {
setValidationError(result.available ? null : "You already have a room with this name");
setIsAvailable(result.available);
}
} catch {
setValidationError("Failed to check room name availability");
setIsAvailable(false);
} finally {
setIsValidating(false);
}
}, []);

// Debounce validation
useEffect(() => {
const timeoutId = setTimeout(() => {
validateRoomName(roomTitle);
}, 500);

return () => clearTimeout(timeoutId);
}, [roomTitle, validateRoomName]);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!roomTitle.trim()) return;
if (!roomTitle.trim() || isAvailable === false) return;

await onSubmit(roomTitle.trim());
setRoomTitle("");
setValidationError(null);
setIsAvailable(null);
};

const handleCancel = () => {
setRoomTitle("");
setValidationError(null);
setIsAvailable(null);
onCancel();
};

Expand All @@ -46,16 +92,34 @@ export default function RoomCreationForm({
value={roomTitle}
onChange={(e) => setRoomTitle(e.target.value)}
placeholder="Enter a title for your room..."
className="text-text bg-card w-full px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
className={`text-text bg-card w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
validationError
? "border-red-500 focus:ring-red-500"
: isAvailable === true
? "border-green-500 focus:ring-green-500"
: "border-border focus:ring-yellow-500"
}`}
required
disabled={isLoading}
aria-busy={isLoading}
/>
{/* Validation feedback */}
<div className="mt-2 min-h-[1.25rem]">
{isValidating && (
<p className="text-sm text-gray-500">Checking availability...</p>
)}
{validationError && (
<p className="text-sm text-red-500">{validationError}</p>
)}
{isAvailable === true && !isValidating && (
<p className="text-sm text-green-500">✓ Room name is available</p>
)}
</div>
</div>
<div className="flex space-x-3">
<button
type="submit"
disabled={isLoading || !roomTitle.trim()}
disabled={isLoading || !roomTitle.trim() || isAvailable === false || isValidating}
className="px-4 py-2 bg-yapli-teal text-black rounded-md hover:bg-yapli-hover disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isLoading ? "Creating..." : "Create Room"}
Expand Down
4 changes: 3 additions & 1 deletion src/lib/roomApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export const createRoom = async (title: string): Promise<void> => {
});

if (!response.ok) {
throw new Error("Failed to create room");
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error || "Failed to create room";
throw new Error(errorMessage);
}
};

Expand Down
37 changes: 37 additions & 0 deletions src/lib/roomNameValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface RoomNameCheckResponse {
available: boolean;
title: string;
error?: string;
}

export const checkRoomNameAvailability = async (
title: string
): Promise<RoomNameCheckResponse> => {
try {
const response = await fetch("/api/rooms/check-name", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title }),
});

const data = await response.json();

if (!response.ok) {
return {
available: false,
title,
error: data.error || "Failed to check room name availability",
};
}

return data;
} catch {
return {
available: false,
title,
error: "Network error while checking room name availability",
};
}
};